See #170: Funkwhale federation

This commit is contained in:
Eliot Berriot 2020-03-25 15:32:10 +01:00
parent fce4d87551
commit 9aa12db62e
20 changed files with 3722 additions and 125 deletions

View File

@ -107,6 +107,4 @@ def generate_actor(username, **kwargs):
@receiver(post_delete, sender=Channel) @receiver(post_delete, sender=Channel)
def delete_channel_related_objs(instance, **kwargs): def delete_channel_related_objs(instance, **kwargs):
instance.library.delete() instance.library.delete()
if instance.actor != instance.attributed_to:
instance.actor.delete()
instance.artist.delete() instance.artist.delete()

View File

@ -13,10 +13,12 @@ from django.utils import timezone
from funkwhale_api.common import locales from funkwhale_api.common import locales
from funkwhale_api.common import permissions from funkwhale_api.common import permissions
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common.mixins import MultipleLookupDetailMixin from funkwhale_api.common.mixins import MultipleLookupDetailMixin
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
@ -128,6 +130,8 @@ class ChannelViewSet(
) )
# prefetch stuff # prefetch stuff
subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk) subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
if not object.actor.is_local:
routes.outbox.dispatch({"type": "Follow"}, context={"follow": subscription})
data = serializers.SubscriptionSerializer(subscription).data data = serializers.SubscriptionSerializer(subscription).data
return response.Response(data, status=201) return response.Response(data, status=201)
@ -139,7 +143,15 @@ class ChannelViewSet(
) )
def unsubscribe(self, request, *args, **kwargs): def unsubscribe(self, request, *args, **kwargs):
object = self.get_object() object = self.get_object()
request.user.actor.emitted_follows.filter(target=object.actor).delete() follow_qs = request.user.actor.emitted_follows.filter(target=object.actor)
follow = follow_qs.first()
if follow:
if not object.actor.is_local:
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}},
context={"follow": follow},
)
follow_qs.delete()
return response.Response(status=204) return response.Response(status=204)
@decorators.action( @decorators.action(
@ -248,11 +260,10 @@ class ChannelViewSet(
@transaction.atomic @transaction.atomic
def perform_destroy(self, instance): def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": instance.actor.type}},
context={"actor": instance.actor},
)
instance.__class__.objects.filter(pk=instance.pk).delete() instance.__class__.objects.filter(pk=instance.pk).delete()
common_utils.on_commit(
federation_tasks.remove_actor.delay, actor_id=instance.actor.pk
)
class SubscriptionsViewSet( class SubscriptionsViewSet(

View File

@ -7,6 +7,7 @@ from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields as common_fields from funkwhale_api.common import fields as common_fields
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
@ -171,6 +172,7 @@ FETCH_OBJECT_CONFIG = {
"library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"}, "library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"},
"upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"}, "upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"},
"account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"}, "account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"},
"channel": {"queryset": audio_models.Channel.objects.all(), "id_attr": "uuid"},
} }
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG) FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)

View File

@ -1,3 +1,5 @@
from . import schema_org
CONTEXTS = [ CONTEXTS = [
{ {
"shortId": "LDP", "shortId": "LDP",
@ -218,6 +220,12 @@ CONTEXTS = [
} }
}, },
}, },
{
"shortId": "SC",
"contextUrl": None,
"documentUrl": "http://schema.org",
"document": {"@context": schema_org.CONTEXT},
},
{ {
"shortId": "SEC", "shortId": "SEC",
"contextUrl": None, "contextUrl": None,
@ -280,6 +288,7 @@ CONTEXTS = [
"type": "@type", "type": "@type",
"as": "https://www.w3.org/ns/activitystreams#", "as": "https://www.w3.org/ns/activitystreams#",
"fw": "https://funkwhale.audio/ns#", "fw": "https://funkwhale.audio/ns#",
"schema": "http://schema.org#",
"xsd": "http://www.w3.org/2001/XMLSchema#", "xsd": "http://www.w3.org/2001/XMLSchema#",
"Album": "fw:Album", "Album": "fw:Album",
"Track": "fw:Track", "Track": "fw:Track",
@ -298,6 +307,8 @@ CONTEXTS = [
"musicbrainzId": "fw:musicbrainzId", "musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"}, "license": {"@id": "fw:license", "@type": "@id"},
"copyright": "fw:copyright", "copyright": "fw:copyright",
"category": "schema:category",
"language": "schema:inLanguage",
} }
}, },
}, },
@ -364,4 +375,5 @@ AS = NS(CONTEXTS_BY_ID["AS"])
LDP = NS(CONTEXTS_BY_ID["LDP"]) LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"]) SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"]) FW = NS(CONTEXTS_BY_ID["FW"])
SC = NS(CONTEXTS_BY_ID["SC"])
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"]) LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])

View File

@ -1,5 +1,7 @@
import logging import logging
from django.db.models import Q
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from . import activity from . import activity
@ -158,18 +160,26 @@ def outbox_create_audio(context):
@inbox.register({"type": "Create", "object.type": "Audio"}) @inbox.register({"type": "Create", "object.type": "Audio"})
def inbox_create_audio(payload, context): def inbox_create_audio(payload, context):
serializer = serializers.UploadSerializer( is_channel = "library" not in payload["object"]
data=payload["object"], if is_channel:
context={"activity": context.get("activity"), "actor": context["actor"]}, channel = context["actor"].get_channel()
) serializer = serializers.ChannelUploadSerializer(
data=payload["object"], context={"channel": channel},
)
else:
serializer = serializers.UploadSerializer(
data=payload["object"],
context={"activity": context.get("activity"), "actor": context["actor"]},
)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.warn("Discarding invalid audio create") logger.warn("Discarding invalid audio create")
return return
upload = serializer.save() upload = serializer.save()
if is_channel:
return {"object": upload, "target": upload.library} return {"object": upload, "target": channel}
else:
return {"object": upload, "target": upload.library}
@inbox.register({"type": "Delete", "object.type": "Library"}) @inbox.register({"type": "Delete", "object.type": "Library"})
@ -252,9 +262,10 @@ def inbox_delete_audio(payload, context):
# we did not receive a list of Ids, so we can probably use the value directly # we did not receive a list of Ids, so we can probably use the value directly
upload_fids = [payload["object"]["id"]] upload_fids = [payload["object"]["id"]]
candidates = music_models.Upload.objects.filter( query = Q(fid__in=upload_fids) & (
library__actor=actor, fid__in=upload_fids Q(library__actor=actor) | Q(track__artist__channel__actor=actor)
) )
candidates = music_models.Upload.objects.filter(query)
total = candidates.count() total = candidates.count()
logger.info("Deleting %s uploads with ids %s", total, upload_fids) logger.info("Deleting %s uploads with ids %s", total, upload_fids)
@ -483,3 +494,44 @@ def outbox_flag(context):
to=[{"type": "actor_inbox", "actor": report.target_owner}], to=[{"type": "actor_inbox", "actor": report.target_owner}],
), ),
} }
@inbox.register({"type": "Delete", "object.type": "Album"})
def inbox_delete_album(payload, context):
actor = context["actor"]
album_id = payload["object"].get("id")
if not album_id:
logger.debug("Discarding deletion of empty library")
return
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor))
try:
album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist:
logger.debug("Discarding deletion of unkwnown album %s", album_id)
return
album.delete()
@outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context):
album = context["album"]
actor = (
album.artist.channel.actor
if album.artist.get_channel()
else album.attributed_to
)
actor = actor or actors.get_service_actor()
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@ import uuid
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
@ -23,6 +24,34 @@ from . import activity, actors, contexts, jsonld, models, tasks, utils
logger = logging.getLogger(__name__) 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): class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length") self.truncate_length = kwargs.pop("truncate_length")
@ -35,6 +64,38 @@ class TruncatedCharField(serializers.CharField):
return v 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): class MediaSerializer(jsonld.JsonLdSerializer):
mediaType = serializers.CharField() mediaType = serializers.CharField()
@ -52,30 +113,51 @@ class MediaSerializer(jsonld.JsonLdSerializer):
if self.allow_empty_mimetype and not v: if self.allow_empty_mimetype and not v:
return None return None
for mt in self.allowed_mimetypes: if not is_mimetype(v, self.allowed_mimetypes):
raise serializers.ValidationError(
if mt.endswith("/*"): "Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
if v.startswith(mt.replace("*", "")): )
return v return v
else:
if v == mt:
return v
raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
)
class LinkSerializer(MediaSerializer): class LinkSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link]) type = serializers.ChoiceField(choices=[contexts.AS.Link])
href = serializers.URLField(max_length=500) href = serializers.URLField(max_length=500)
bitrate = serializers.IntegerField(min_value=0, required=False)
size = serializers.IntegerField(min_value=0, required=False)
class Meta: class Meta:
jsonld_mapping = { jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href), "href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType), "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): class ImageSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link]) type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link])
href = serializers.URLField(max_length=500, required=False) href = serializers.URLField(max_length=500, required=False)
@ -133,6 +215,16 @@ def get_by_media_type(urls, media_type):
return url 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): class ActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
outbox = serializers.URLField(max_length=500, required=False) outbox = serializers.URLField(max_length=500, required=False)
@ -163,6 +255,16 @@ class ActorSerializer(jsonld.JsonLdSerializer):
required=False, required=False,
allow_empty_mimetype=True, 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",
# )
class Meta: class Meta:
# not strictly necessary because it's not a model serializer # not strictly necessary because it's not a model serializer
@ -185,8 +287,19 @@ class ActorSerializer(jsonld.JsonLdSerializer):
"endpoints": jsonld.first_obj(contexts.AS.endpoints), "endpoints": jsonld.first_obj(contexts.AS.endpoints),
"icon": jsonld.first_obj(contexts.AS.icon), "icon": jsonld.first_obj(contexts.AS.icon),
"url": jsonld.raw(contexts.AS.url), "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): def to_representation(self, instance):
ret = { ret = {
"id": instance.fid, "id": instance.fid,
@ -231,6 +344,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
include_image(ret, channel.artist.attachment_cover, "icon") include_image(ret, channel.artist.attachment_cover, "icon")
if channel.artist.description_id: if channel.artist.description_id:
ret["summary"] = channel.artist.description.rendered 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: else:
ret["url"] = [ ret["url"] = [
{ {
@ -312,6 +428,22 @@ class ActorSerializer(jsonld.JsonLdSerializer):
if new_value if new_value
else None, 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 return actor
def validate(self, data): def validate(self, data):
@ -326,6 +458,56 @@ class ActorSerializer(jsonld.JsonLdSerializer):
return validated_data 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 APIActorSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Actor model = models.Actor
@ -936,20 +1118,6 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
} }
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): def repr_tag(tag_name):
return {"type": "Hashtag", "name": "#{}".format(tag_name)} return {"type": "Hashtag", "name": "#{}".format(tag_name)}
@ -1025,10 +1193,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
return instance return instance
def get_tags_repr(self, instance): def get_tags_repr(self, instance):
return [ return tag_list(instance.tagged_items.all())
repr_tag(item.tag.name)
for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
]
def validate_updated_data(self, instance, validated_data): def validate_updated_data(self, instance, validated_data):
try: try:
@ -1108,7 +1273,10 @@ class ArtistSerializer(MusicEntitySerializer):
class AlbumSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False) released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artists = serializers.ListField(
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
# XXX: 1.0 rename to image # XXX: 1.0 rename to image
cover = ImageSerializer( cover = ImageSerializer(
allowed_mimetypes=["image/*"], allowed_mimetypes=["image/*"],
@ -1146,16 +1314,24 @@ class AlbumSerializer(MusicEntitySerializer):
"released": instance.release_date.isoformat() "released": instance.release_date.isoformat()
if instance.release_date if instance.release_date
else None, else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
],
"attributedTo": instance.attributed_to.fid "attributedTo": instance.attributed_to.fid
if instance.attributed_to if instance.attributed_to
else None, else None,
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
if instance.artist.get_channel():
d["artists"] = [
{
"type": instance.artist.channel.actor.type,
"id": instance.artist.channel.actor.fid,
}
]
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
]
include_content(d, instance.description) include_content(d, instance.description)
if instance.attachment_cover: if instance.attachment_cover:
d["cover"] = { d["cover"] = {
@ -1172,12 +1348,18 @@ class AlbumSerializer(MusicEntitySerializer):
def validate(self, data): def validate(self, data):
validated_data = super().validate(data) validated_data = super().validate(data)
if not self.parent: if not self.parent:
validated_data["_artist"] = utils.retrieve_ap_object( artist_data = validated_data["artists"][0]
validated_data["artists"][0]["id"], if artist_data.get("type", "Artist") == "Artist":
actor=self.context.get("fetch_actor"), validated_data["_artist"] = utils.retrieve_ap_object(
queryset=music_models.Artist, artist_data["id"],
serializer_class=ArtistSerializer, actor=self.context.get("fetch_actor"),
) queryset=music_models.Artist,
serializer_class=ArtistSerializer,
)
else:
# we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"])
validated_data["_artist"] = actor.channel.artist
return validated_data return validated_data
@ -1569,31 +1751,116 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
return r return r
class ChannelUploadSerializer(serializers.Serializer): class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = TruncatedCharField(truncate_length=music_models.MAX_LENGTHS["TRACK_TITLE"])
published = serializers.DateTimeField(required=False)
duration = serializers.IntegerField(min_value=0, required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False)
copyright = TruncatedCharField(
truncate_length=music_models.MAX_LENGTHS["COPYRIGHT"],
allow_null=True,
required=False,
)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=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,
)
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"name": jsonld.first_val(contexts.AS.name),
"url": jsonld.raw(contexts.AS.url),
"published": jsonld.first_val(contexts.AS.published),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
"duration": jsonld.first_val(contexts.AS.duration),
"album": jsonld.first_id(contexts.FW.album),
"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),
"image": jsonld.first_obj(contexts.AS.image),
"tags": jsonld.raw(contexts.AS.tag),
}
def validate_album(self, v):
return utils.retrieve_ap_object(
v,
actor=actors.get_service_actor(),
serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter(
artist__channel=self.context["channel"]
),
)
def validate(self, data):
validated_data = super().validate(data)
if data.get("content"):
validated_data["description"] = {
"content_type": data["mediaType"],
"text": data["content"],
}
return validated_data
def to_representation(self, upload): def to_representation(self, upload):
data = { data = {
"id": upload.fid, "id": upload.fid,
"type": "Audio", "type": "Audio",
"name": upload.track.full_name, "name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid, "attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(), "published": upload.creation_date.isoformat(),
"to": contexts.AS.Public "to": contexts.AS.Public
if upload.library.privacy_level == "everyone" if upload.library.privacy_level == "everyone"
else "", else "",
"url": [ "url": [
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
},
{ {
"type": "Link", "type": "Link",
"mediaType": "text/html", "mediaType": "text/html",
"href": utils.full_url(upload.track.get_absolute_url()), "href": utils.full_url(upload.track.get_absolute_url()),
}, },
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
},
], ],
} }
if upload.track.album:
data["album"] = upload.track.album.fid
if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, upload.duration, "duration")
include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright")
include_if_not_none(data["url"][1], upload.bitrate, "bitrate")
include_if_not_none(data["url"][1], upload.size, "size")
include_content(data, upload.track.description) include_content(data, upload.track.description)
include_image(data, upload.track.attachment_cover)
tags = [item.tag.name for item in upload.get_all_tagged_items()] tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags: if tags:
data["tag"] = [repr_tag(name) for name in tags] data["tag"] = [repr_tag(name) for name in tags]
@ -1604,6 +1871,68 @@ class ChannelUploadSerializer(serializers.Serializer):
return data return data
def update(self, instance, validated_data):
return self.update_or_create(validated_data)
@transaction.atomic
def update_or_create(self, validated_data):
channel = self.context["channel"]
now = timezone.now()
track_defaults = {
"fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1),
"title": validated_data["name"],
"copyright": validated_data.get("copyright"),
"attributed_to": channel.attributed_to,
"album": validated_data.get("album"),
"creation_date": validated_data.get("published", now),
}
if validated_data.get("license"):
track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create(
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults
)
if "image" in validated_data:
new_value = self.validated_data["image"]
common_utils.attach_file(
track,
"attachment_cover",
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
)
common_utils.attach_content(
track, "description", validated_data.get("description")
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(track, *tags)
upload_defaults = {
"fid": validated_data["id"],
"track": track,
"library": channel.library,
"creation_date": validated_data.get("published", now),
"duration": validated_data.get("duration"),
"bitrate": validated_data["url"][0].get("bitrate"),
"size": validated_data["url"][0].get("size"),
"mimetype": validated_data["url"][0]["mediaType"],
"source": validated_data["url"][0]["href"],
"import_status": "finished",
}
upload, created = music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=upload_defaults
)
return upload
def create(self, validated_data):
return self.update_or_create(validated_data)
class ChannelCreateUploadSerializer(serializers.Serializer): class ChannelCreateUploadSerializer(serializers.Serializer):
def to_representation(self, upload): def to_representation(self, upload):

View File

@ -7,11 +7,14 @@ import requests
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Q, F from django.db.models import Q, F
from django.db.models.deletion import Collector
from django.utils import timezone from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from requests.exceptions import RequestException from requests.exceptions import RequestException
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import models as common_models
from funkwhale_api.common import session from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
@ -254,8 +257,11 @@ def handle_purge_actors(ids, only=[]):
# purge audio content # purge audio content
if not only or "media" in only: if not only or "media" in only:
delete_qs(common_models.Attachment.objects.filter(actor__in=ids))
delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids)) delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
delete_qs(models.Follow.objects.filter(target_id__in=ids)) delete_qs(models.Follow.objects.filter(target_id__in=ids))
delete_qs(audio_models.Channel.objects.filter(attributed_to__in=ids))
delete_qs(audio_models.Channel.objects.filter(actor__in=ids))
delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids)) delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
delete_qs(music_models.Library.objects.filter(actor_id__in=ids)) delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
@ -390,9 +396,76 @@ def fetch(fetch_obj):
error("save", message=str(e)) error("save", message=str(e))
raise raise
# special case for channels
# when obj is an actor, we check if the actor has a channel associated with it
# if it is the case, we consider the fetch obj to be a channel instead
if isinstance(obj, models.Actor) and obj.get_channel():
obj = obj.get_channel()
fetch_obj.object = obj fetch_obj.object = obj
fetch_obj.status = "finished" fetch_obj.status = "finished"
fetch_obj.fetch_date = timezone.now() fetch_obj.fetch_date = timezone.now()
return fetch_obj.save( return fetch_obj.save(
update_fields=["fetch_date", "status", "object_id", "object_content_type"] update_fields=["fetch_date", "status", "object_id", "object_content_type"]
) )
class PreserveSomeDataCollector(Collector):
"""
We need to delete everything related to an actor. Well Almost everything.
But definitely not the Delete Activity we send to announce the actor is deleted.
"""
def __init__(self, *args, **kwargs):
self.creation_date = timezone.now()
super().__init__(*args, **kwargs)
def related_objects(self, related, *args, **kwargs):
qs = super().related_objects(related, *args, **kwargs)
if related.name == "outbox_activities":
# exclude the delete activity can be broadcasted properly
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
return qs
@celery.app.task(name="federation.remove_actor")
@transaction.atomic
@celery.require_instance(
models.Actor.objects.all(), "actor",
)
def remove_actor(actor):
# Then we broadcast the info over federation. We do this *before* deleting objects
# associated with the actor, otherwise follows are removed and we don't know where
# to broadcast
logger.info("Broadcasting deletion to federation…")
collector = PreserveSomeDataCollector(using="default")
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
)
# then we delete any object associated with the actor object, but *not* the actor
# itself. We keep it for auditability and sending the Delete ActivityPub message
logger.info(
"Prepare deletion of objects associated with account %s",
actor.preferred_username,
)
collector.collect([actor])
for model, instances in collector.data.items():
if issubclass(model, actor.__class__):
# we skip deletion of the actor itself
continue
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
logger.info(
"Deleting %s objects associated with account %s",
len(instances),
actor.preferred_username,
)
to_delete.delete()
# Finally, we update the actor itself and mark it as removed
logger.info("Marking actor as Tombsone…")
actor.type = "Tombstone"
actor.name = None
actor.summary = None
actor.save(update_fields=["type", "name", "summary"])

View File

@ -67,7 +67,11 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
lookup_field = "preferred_username" lookup_field = "preferred_username"
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = models.Actor.objects.local().select_related("user") queryset = (
models.Actor.objects.local()
.select_related("user", "channel__artist", "channel__attributed_to")
.prefetch_related("channel__artist__tagged_items__tag")
)
serializer_class = serializers.ActorSerializer serializer_class = serializers.ActorSerializer
def get_queryset(self): def get_queryset(self):

View File

@ -241,6 +241,14 @@ class AlbumViewSet(
return serializers.AlbumCreateSerializer return serializers.AlbumCreateSerializer
return super().get_serializer_class() return super().get_serializer_class()
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": "Album"}},
context={"album": instance},
)
models.Album.objects.filter(pk=instance.pk).delete()
class LibraryViewSet( class LibraryViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
@ -380,6 +388,15 @@ class TrackViewSet(
context["description"] = self.action in ["retrieve", "create", "update"] context["description"] = self.action in ["retrieve", "create", "update"]
return context return context
@transaction.atomic
def perform_destroy(self, instance):
uploads = instance.uploads.order_by("id")
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": "Audio"}},
context={"uploads": list(uploads)},
)
instance.delete()
def strip_absolute_media_url(path): def strip_absolute_media_url(path):
if ( if (

View File

@ -1,8 +1,6 @@
import logging import logging
from django.db.models.deletion import Collector from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.federation import routes
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
from . import models from . import models
@ -20,39 +18,6 @@ def delete_account(user):
user.delete() user.delete()
logger.info("Deleted user object") logger.info("Deleted user object")
# Then we broadcast the info over federation. We do this *before* deleting objects # ensure actor is set to tombstone, activities are removed, etc.
# associated with the actor, otherwise follows are removed and we don't know where federation_tasks.remove_actor(actor_id=actor.pk)
# to broadcast logger.info("Deletion of account done %s!", actor.preferred_username)
logger.info("Broadcasting deletion to federation…")
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
)
# then we delete any object associated with the actor object, but *not* the actor
# itself. We keep it for auditability and sending the Delete ActivityPub message
collector = Collector(using="default")
logger.info(
"Prepare deletion of objects associated with account %s", user.username
)
collector.collect([actor])
for model, instances in collector.data.items():
if issubclass(model, actor.__class__):
# we skip deletion of the actor itself
continue
logger.info(
"Deleting %s objects associated with account %s",
len(instances),
user.username,
)
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
to_delete.delete()
# Finally, we update the actor itself and mark it as removed
logger.info("Marking actor as Tombsone…")
actor.type = "Tombstone"
actor.name = None
actor.summary = None
actor.save(update_fields=["type", "name", "summary"])
logger.info("Deletion of account done %s!", user.username)

View File

@ -148,19 +148,17 @@ def test_channel_delete(logged_in_api_client, factories, mocker):
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
assert response.status_code == 204 assert response.status_code == 204
on_commit.assert_called_once_with(
views.federation_tasks.remove_actor.delay, actor_id=channel.actor.pk
)
with pytest.raises(channel.DoesNotExist): with pytest.raises(channel.DoesNotExist):
channel.refresh_from_db() channel.refresh_from_db()
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": channel.actor.type}},
context={"actor": channel.actor},
)
def test_channel_delete_permission(logged_in_api_client, factories): def test_channel_delete_permission(logged_in_api_client, factories):
logged_in_api_client.user.create_actor() logged_in_api_client.user.create_actor()
@ -218,6 +216,38 @@ def test_channel_unsubscribe(factories, logged_in_api_client):
subscription.refresh_from_db() subscription.refresh_from_db()
def test_channel_subscribe_remote(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel_actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](artist__description=None, actor=channel_actor)
url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
assert response.status_code == 201
subscription = actor.emitted_follows.latest("id")
dispatch.assert_called_once_with(
{"type": "Follow"}, context={"follow": subscription}
)
def test_channel_unsubscribe_remote(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel_actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](actor=channel_actor)
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": subscription}
)
def test_subscriptions_list(factories, logged_in_api_client): def test_subscriptions_list(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]( channel = factories["audio.Channel"](

View File

@ -167,6 +167,7 @@ def test_fetch_serializer_no_obj(factories, to_api_date):
("music.Track", "track", "id"), ("music.Track", "track", "id"),
("music.Library", "library", "uuid"), ("music.Library", "library", "uuid"),
("music.Upload", "upload", "uuid"), ("music.Upload", "upload", "uuid"),
("audio.Channel", "channel", "uuid"),
("federation.Actor", "account", "full_username"), ("federation.Actor", "account", "full_username"),
], ],
) )

View File

@ -26,6 +26,7 @@ from funkwhale_api.moderation import serializers as moderation_serializers
routes.inbox_delete_library, routes.inbox_delete_library,
), ),
({"type": "Delete", "object": {"type": "Audio"}}, routes.inbox_delete_audio), ({"type": "Delete", "object": {"type": "Audio"}}, routes.inbox_delete_audio),
({"type": "Delete", "object": {"type": "Album"}}, routes.inbox_delete_album),
({"type": "Undo", "object": {"type": "Follow"}}, routes.inbox_undo_follow), ({"type": "Undo", "object": {"type": "Follow"}}, routes.inbox_undo_follow),
({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist), ({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist),
({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album), ({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album),
@ -58,6 +59,7 @@ def test_inbox_routes(route, handler):
routes.outbox_delete_library, routes.outbox_delete_library,
), ),
({"type": "Delete", "object": {"type": "Audio"}}, routes.outbox_delete_audio), ({"type": "Delete", "object": {"type": "Audio"}}, routes.outbox_delete_audio),
({"type": "Delete", "object": {"type": "Album"}}, routes.outbox_delete_album),
({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow), ({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow),
({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track), ({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track),
( (
@ -349,6 +351,34 @@ def test_inbox_create_audio(factories, mocker):
assert save.call_count == 1 assert save.call_count == 1
def test_inbox_create_audio_channel(factories, mocker):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
upload = factories["music.Upload"](track__album=album, library=channel.library,)
payload = {
"@context": jsonld.get_default_context(),
"type": "Create",
"actor": channel.actor.fid,
"object": serializers.ChannelUploadSerializer(upload).data,
}
upload.delete()
init = mocker.spy(serializers.ChannelUploadSerializer, "__init__")
save = mocker.spy(serializers.ChannelUploadSerializer, "save")
result = routes.inbox_create_audio(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
assert channel.library.uploads.count() == 1
assert result == {"object": channel.library.uploads.latest("id"), "target": channel}
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == payload["object"]
assert args[1]["context"] == {"channel": channel}
assert save.call_count == 1
def test_inbox_delete_library(factories): def test_inbox_delete_library(factories):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
@ -368,6 +398,73 @@ def test_inbox_delete_library(factories):
library.refresh_from_db() library.refresh_from_db()
def test_inbox_delete_album(factories):
album = factories["music.Album"](attributed=True)
payload = {
"type": "Delete",
"actor": album.attributed_to.fid,
"object": {"type": "Album", "id": album.fid},
}
routes.inbox_delete_album(
payload,
context={
"actor": album.attributed_to,
"raise_exception": True,
"activity": activity,
},
)
with pytest.raises(album.__class__.DoesNotExist):
album.refresh_from_db()
def test_inbox_delete_album_channel(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
"object": {"type": "Album", "id": album.fid},
}
routes.inbox_delete_album(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(album.__class__.DoesNotExist):
album.refresh_from_db()
def test_outbox_delete_album(factories):
album = factories["music.Album"](attributed=True)
a = list(routes.outbox_delete_album({"album": album}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
).data
expected["to"] = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
assert dict(a["payload"]) == dict(expected)
assert a["actor"] == album.attributed_to
def test_outbox_delete_album_channel(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
a = list(routes.outbox_delete_album({"album": album}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
).data
expected["to"] = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
assert dict(a["payload"]) == dict(expected)
assert a["actor"] == channel.actor
def test_inbox_delete_library_impostor(factories): def test_inbox_delete_library_impostor(factories):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]() impostor = factories["federation.Actor"]()
@ -469,6 +566,25 @@ def test_inbox_delete_audio(factories):
upload.refresh_from_db() upload.refresh_from_db()
def test_inbox_delete_audio_channel(factories):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
upload = factories["music.Upload"](track__artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
"object": {"type": "Audio", "id": [upload.fid]},
}
routes.inbox_delete_audio(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(upload.__class__.DoesNotExist):
upload.refresh_from_db()
def test_inbox_delete_audio_impostor(factories): def test_inbox_delete_audio_impostor(factories):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]() impostor = factories["federation.Actor"]()

View File

@ -795,6 +795,17 @@ def test_activity_pub_album_serializer_to_ap(factories):
assert serializer.data == expected assert serializer.data == expected
def test_activity_pub_album_serializer_to_ap_channel_artist(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist,)
serializer = serializers.AlbumSerializer(album)
assert serializer.data["artists"] == [
{"type": channel.actor.type, "id": channel.actor.fid}
]
def test_activity_pub_album_serializer_from_ap_create(factories, faker, now): def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
@ -836,6 +847,30 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
] ]
def test_activity_pub_album_serializer_from_ap_create_channel_artist(
factories, faker, now
):
actor = factories["federation.Actor"]()
channel = factories["audio.Channel"]()
released = faker.date_object()
payload = {
"@context": jsonld.get_default_context(),
"type": "Album",
"id": "https://album.example",
"name": faker.sentence(),
"published": now.isoformat(),
"released": released.isoformat(),
"artists": [{"type": channel.actor.type, "id": channel.actor.fid}],
"attributedTo": actor.fid,
}
serializer = serializers.AlbumSerializer(data=payload)
assert serializer.is_valid(raise_exception=True) is True
album = serializer.save()
assert album.artist == channel.artist
def test_activity_pub_album_serializer_from_ap_update(factories, faker): def test_activity_pub_album_serializer_from_ap_update(factories, faker):
album = factories["music.Album"](attributed=True) album = factories["music.Album"](attributed=True)
released = faker.date_object() released = faker.date_object()
@ -1395,7 +1430,9 @@ def test_track_serializer_update_license(factories):
def test_channel_actor_serializer(factories): def test_channel_actor_serializer(factories):
channel = factories["audio.Channel"]( channel = factories["audio.Channel"](
actor__attachment_icon=None, artist__with_cover=True actor__attachment_icon=None,
artist__with_cover=True,
artist__set_tags=["punk", "rock"],
) )
serializer = serializers.ActorSerializer(channel.actor) serializer = serializers.ActorSerializer(channel.actor)
@ -1418,6 +1455,164 @@ def test_channel_actor_serializer(factories):
} }
assert serializer.data["url"] == expected_url assert serializer.data["url"] == expected_url
assert serializer.data["icon"] == expected_icon assert serializer.data["icon"] == expected_icon
assert serializer.data["attributedTo"] == channel.attributed_to.fid
assert serializer.data["category"] == channel.artist.content_category
assert serializer.data["tag"] == [
{"type": "Hashtag", "name": "#punk"},
{"type": "Hashtag", "name": "#rock"},
]
def test_channel_actor_serializer_from_ap_create(mocker, factories):
domain = factories["federation.Domain"](name="test.pod")
attributed_to = factories["federation.Actor"](domain=domain)
get_actor = mocker.patch.object(actors, "get_actor", return_value=attributed_to)
actor_data = {
"@context": jsonld.get_default_context(),
"followers": "https://test.pod/federation/actors/mychannel/followers",
"preferredUsername": "mychannel",
"id": "https://test.pod/federation/actors/mychannel",
"endpoints": {"sharedInbox": "https://test.pod/federation/shared/inbox"},
"name": "mychannel",
"following": "https://test.pod/federation/actors/mychannel/following",
"outbox": "https://test.pod/federation/actors/mychannel/outbox",
"url": [
{
"mediaType": "text/html",
"href": "https://test.pod/channels/mychannel",
"type": "Link",
},
{
"mediaType": "application/rss+xml",
"href": "https://test.pod/api/v1/channels/mychannel/rss",
"type": "Link",
},
],
"type": "Person",
"category": "podcast",
"attributedTo": attributed_to.fid,
"manuallyApprovesFollowers": False,
"inbox": "https://test.pod/federation/actors/mychannel/inbox",
"icon": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://test.pod/media/attachments/dd/ce/b2/nosmile.jpeg",
},
"summary": "<p>content</p>",
"publicKey": {
"owner": "https://test.pod/federation/actors/mychannel",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\n+KwIDAQAB\n-----END RSA PUBLIC KEY-----\n",
"id": "https://test.pod/federation/actors/mychannel#main-key",
},
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
}
serializer = serializers.ActorSerializer(data=actor_data)
assert serializer.is_valid(raise_exception=True) is True
actor = serializer.save()
get_actor.assert_called_once_with(actor_data["attributedTo"])
assert actor.preferred_username == actor_data["preferredUsername"]
assert actor.fid == actor_data["id"]
assert actor.name == actor_data["name"]
assert actor.type == actor_data["type"]
assert actor.public_key == actor_data["publicKey"]["publicKeyPem"]
assert actor.outbox_url == actor_data["outbox"]
assert actor.inbox_url == actor_data["inbox"]
assert actor.shared_inbox_url == actor_data["endpoints"]["sharedInbox"]
assert actor.channel.attributed_to == attributed_to
assert actor.channel.rss_url == actor_data["url"][1]["href"]
assert actor.channel.artist.attributed_to == attributed_to
assert actor.channel.artist.content_category == actor_data["category"]
assert actor.channel.artist.name == actor_data["name"]
assert actor.channel.artist.get_tags() == ["Indie", "Punk", "Rock"]
assert actor.channel.artist.description.text == actor_data["summary"]
assert actor.channel.artist.description.content_type == "text/html"
assert actor.channel.artist.attachment_cover.url == actor_data["icon"]["url"]
assert (
actor.channel.artist.attachment_cover.mimetype
== actor_data["icon"]["mediaType"]
)
assert actor.channel.library.fid is not None
assert actor.channel.library.actor == attributed_to
assert actor.channel.library.privacy_level == "everyone"
assert actor.channel.library.name == actor_data["name"]
def test_channel_actor_serializer_from_ap_update(mocker, factories):
domain = factories["federation.Domain"](name="test.pod")
attributed_to = factories["federation.Actor"](domain=domain)
actor = factories["federation.Actor"](domain=domain)
channel = factories["audio.Channel"](actor=actor, attributed_to=attributed_to)
get_actor = mocker.patch.object(actors, "get_actor", return_value=attributed_to)
library = channel.library
actor_data = {
"@context": jsonld.get_default_context(),
"followers": "https://test.pod/federation/actors/mychannel/followers",
"preferredUsername": "mychannel",
"id": actor.fid,
"endpoints": {"sharedInbox": "https://test.pod/federation/shared/inbox"},
"name": "mychannel",
"following": "https://test.pod/federation/actors/mychannel/following",
"outbox": "https://test.pod/federation/actors/mychannel/outbox",
"url": [
{
"mediaType": "text/html",
"href": "https://test.pod/channels/mychannel",
"type": "Link",
},
{
"mediaType": "application/rss+xml",
"href": "https://test.pod/api/v1/channels/mychannel/rss",
"type": "Link",
},
],
"type": "Person",
"category": "podcast",
"attributedTo": attributed_to.fid,
"manuallyApprovesFollowers": False,
"inbox": "https://test.pod/federation/actors/mychannel/inbox",
"icon": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://test.pod/media/attachments/dd/ce/b2/nosmile.jpeg",
},
"summary": "<p>content</p>",
"publicKey": {
"owner": "https://test.pod/federation/actors/mychannel",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\n+KwIDAQAB\n-----END RSA PUBLIC KEY-----\n",
"id": "https://test.pod/federation/actors/mychannel#main-key",
},
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
}
serializer = serializers.ActorSerializer(data=actor_data)
assert serializer.is_valid(raise_exception=True) is True
serializer.save()
channel.refresh_from_db()
get_actor.assert_called_once_with(actor_data["attributedTo"])
assert channel.actor == actor
assert channel.attributed_to == attributed_to
assert channel.rss_url == actor_data["url"][1]["href"]
assert channel.artist.attributed_to == attributed_to
assert channel.artist.content_category == actor_data["category"]
assert channel.artist.name == actor_data["name"]
assert channel.artist.get_tags() == ["Indie", "Punk", "Rock"]
assert channel.artist.description.text == actor_data["summary"]
assert channel.artist.description.content_type == "text/html"
assert channel.artist.attachment_cover.url == actor_data["icon"]["url"]
assert channel.artist.attachment_cover.mimetype == actor_data["icon"]["mediaType"]
assert channel.library.actor == attributed_to
assert channel.library.privacy_level == library.privacy_level
assert channel.library.name == library.name
def test_channel_actor_outbox_serializer(factories): def test_channel_actor_outbox_serializer(factories):
@ -1449,12 +1644,21 @@ def test_channel_actor_outbox_serializer(factories):
def test_channel_upload_serializer(factories): def test_channel_upload_serializer(factories):
channel = factories["audio.Channel"](library__privacy_level="everyone") channel = factories["audio.Channel"](library__privacy_level="everyone")
content = factories["common.Content"]() content = factories["common.Content"]()
cover = factories["common.Attachment"]()
upload = factories["music.Upload"]( upload = factories["music.Upload"](
playable=True, playable=True,
bitrate=543,
size=543,
duration=54,
library=channel.library, library=channel.library,
import_status="finished", import_status="finished",
track__set_tags=["Punk"], track__set_tags=["Punk"],
track__attachment_cover=cover,
track__description=content, track__description=content,
track__disc_number=3,
track__position=12,
track__license="cc0-1.0",
track__copyright="Copyright something",
track__album__set_tags=["Rock"], track__album__set_tags=["Rock"],
track__artist__set_tags=["Indie"], track__artist__set_tags=["Indie"],
) )
@ -1463,25 +1667,38 @@ def test_channel_upload_serializer(factories):
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
"type": "Audio", "type": "Audio",
"id": upload.fid, "id": upload.fid,
"name": upload.track.full_name, "name": upload.track.title,
"summary": "#Indie #Punk #Rock", "summary": "#Indie #Punk #Rock",
"attributedTo": channel.actor.fid, "attributedTo": channel.actor.fid,
"published": upload.creation_date.isoformat(), "published": upload.creation_date.isoformat(),
"mediaType": "text/html", "mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type), "content": common_utils.render_html(content.text, content.content_type),
"to": "https://www.w3.org/ns/activitystreams#Public", "to": "https://www.w3.org/ns/activitystreams#Public",
"position": upload.track.position,
"duration": upload.duration,
"album": upload.track.album.fid,
"disc": upload.track.disc_number,
"copyright": upload.track.copyright,
"license": upload.track.local_license["identifiers"][0],
"url": [ "url": [
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
},
{ {
"type": "Link", "type": "Link",
"mediaType": "text/html", "mediaType": "text/html",
"href": utils.full_url(upload.track.get_absolute_url()), "href": utils.full_url(upload.track.get_absolute_url()),
}, },
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
"bitrate": upload.bitrate,
"size": upload.size,
},
], ],
"image": {
"type": "Image",
"url": upload.track.attachment_cover.download_url_original,
"mediaType": upload.track.attachment_cover.mimetype,
},
"tag": [ "tag": [
{"type": "Hashtag", "name": "#Indie"}, {"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Punk"},
@ -1494,6 +1711,166 @@ def test_channel_upload_serializer(factories):
assert serializer.data == expected assert serializer.data == expected
def test_channel_upload_serializer_from_ap_create(factories, now):
channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist)
payload = {
"@context": jsonld.get_default_context(),
"type": "Audio",
"id": "https://test.pod/uuid",
"name": "My test track",
"summary": "#Indie #Punk #Rock",
"attributedTo": channel.actor.fid,
"published": now.isoformat(),
"mediaType": "text/html",
"content": "<p>Hello</p>",
"duration": 543,
"position": 4,
"disc": 2,
"album": album.fid,
"to": "https://www.w3.org/ns/activitystreams#Public",
"copyright": "Copyright test",
"license": "http://creativecommons.org/publicdomain/zero/1.0/",
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": "https://test.pod/track",
},
{
"type": "Link",
"mediaType": "audio/mpeg",
"href": "https://test.pod/file.mp3",
"bitrate": 192000,
"size": 15492738,
},
],
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": "https://image.example/image.png",
},
}
serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel}
)
assert serializer.is_valid(raise_exception=True) is True
upload = serializer.save(channel=channel)
assert upload.library == channel.library
assert upload.import_status == "finished"
assert upload.creation_date == now
assert upload.fid == payload["id"]
assert upload.source == payload["url"][1]["href"]
assert upload.mimetype == payload["url"][1]["mediaType"]
assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist
assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to
assert upload.track.title == payload["name"]
assert upload.track.creation_date == now
assert upload.track.description.content_type == payload["mediaType"]
assert upload.track.description.text == payload["content"]
assert upload.track.fid == payload["id"]
assert upload.track.license.pk == "cc0-1.0"
assert upload.track.copyright == payload["copyright"]
assert upload.track.get_tags() == ["Indie", "Punk", "Rock"]
assert upload.track.attachment_cover.mimetype == payload["image"]["mediaType"]
assert upload.track.attachment_cover.url == payload["image"]["url"]
assert upload.track.album == album
def test_channel_upload_serializer_from_ap_update(factories, now):
channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist)
upload = factories["music.Upload"](track__album=album, track__artist=channel.artist)
payload = {
"@context": jsonld.get_default_context(),
"type": "Audio",
"id": upload.fid,
"name": "Hello there",
"attributedTo": channel.actor.fid,
"published": now.isoformat(),
"mediaType": "text/html",
"content": "<p>Hello</p>",
"duration": 543,
"position": 4,
"disc": 2,
"album": album.fid,
"to": "https://www.w3.org/ns/activitystreams#Public",
"copyright": "Copyright test",
"license": "http://creativecommons.org/publicdomain/zero/1.0/",
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": "https://test.pod/track",
},
{
"type": "Link",
"mediaType": "audio/mpeg",
"href": "https://test.pod/file.mp3",
"bitrate": 192000,
"size": 15492738,
},
],
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": "https://image.example/image.png",
},
}
serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel}
)
assert serializer.is_valid(raise_exception=True) is True
serializer.save(channel=channel)
upload.refresh_from_db()
assert upload.library == channel.library
assert upload.import_status == "finished"
assert upload.creation_date == now
assert upload.fid == payload["id"]
assert upload.source == payload["url"][1]["href"]
assert upload.mimetype == payload["url"][1]["mediaType"]
assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist
assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to
assert upload.track.title == payload["name"]
assert upload.track.creation_date == now
assert upload.track.description.content_type == payload["mediaType"]
assert upload.track.description.text == payload["content"]
assert upload.track.fid == payload["id"]
assert upload.track.license.pk == "cc0-1.0"
assert upload.track.copyright == payload["copyright"]
assert upload.track.get_tags() == ["Indie", "Punk", "Rock"]
assert upload.track.attachment_cover.mimetype == payload["image"]["mediaType"]
assert upload.track.attachment_cover.url == payload["image"]["url"]
assert upload.track.album == album
def test_channel_create_upload_serializer(factories): def test_channel_create_upload_serializer(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
upload = factories["music.Upload"]( upload = factories["music.Upload"](

View File

@ -491,6 +491,21 @@ def test_fetch_url(factory_name, serializer_class, factories, r_mock, mocker):
assert save.call_count == 1 assert save.call_count == 1
def test_fetch_channel_actor_returns_channel(factories, r_mock):
obj = factories["audio.Channel"]()
fetch = factories["federation.Fetch"](url=obj.actor.fid)
payload = serializers.ActorSerializer(obj.actor).data
r_mock.get(obj.fid, json=payload)
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
assert fetch.status == "finished"
assert fetch.object == obj
def test_fetch_honor_instance_policy_domain(factories): def test_fetch_honor_instance_policy_domain(factories):
domain = factories["moderation.InstancePolicy"]( domain = factories["moderation.InstancePolicy"](
block_all=True, for_domain=True block_all=True, for_domain=True

View File

@ -1407,7 +1407,8 @@ def test_channel_owner_can_create_album(factories, logged_in_api_client):
assert album.description.text == "hello world" assert album.description.text == "hello world"
def test_channel_owner_can_delete_album(factories, logged_in_api_client): def test_channel_owner_can_delete_album(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist=channel.artist)
@ -1416,6 +1417,10 @@ def test_channel_owner_can_delete_album(factories, logged_in_api_client):
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
assert response.status_code == 204 assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": "Album"}}, context={"album": album}
)
with pytest.raises(album.DoesNotExist): with pytest.raises(album.DoesNotExist):
album.refresh_from_db() album.refresh_from_db()
@ -1452,15 +1457,22 @@ def test_other_user_cannot_delete_album(factories, logged_in_api_client):
album.refresh_from_db() album.refresh_from_db()
def test_channel_owner_can_delete_track(factories, logged_in_api_client): def test_channel_owner_can_delete_track(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
track = factories["music.Track"](artist=channel.artist) track = factories["music.Track"](artist=channel.artist)
upload1 = factories["music.Upload"](track=track)
upload2 = factories["music.Upload"](track=track)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
assert response.status_code == 204 assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": "Audio"}},
context={"uploads": [upload1, upload2]},
)
with pytest.raises(track.DoesNotExist): with pytest.raises(track.DoesNotExist):
track.refresh_from_db() track.refresh_from_db()

View File

@ -7,9 +7,10 @@ from funkwhale_api.users import tasks
def test_delete_account(factories, mocker): def test_delete_account(factories, mocker):
user = factories["users.User"]() user = factories["users.User"]()
actor = user.create_actor() actor = user.create_actor()
factories["federation.Follow"](target=actor, approved=True)
library = factories["music.Library"](actor=actor) library = factories["music.Library"](actor=actor)
unrelated_library = factories["music.Library"]() unrelated_library = factories["music.Library"]()
dispatch = mocker.patch.object(routes.outbox, "dispatch") dispatch = mocker.spy(routes.outbox, "dispatch")
tasks.delete_account(user_id=user.pk) tasks.delete_account(user_id=user.pk)
@ -30,3 +31,5 @@ def test_delete_account(factories, mocker):
assert actor.type == "Tombstone" assert actor.type == "Tombstone"
assert actor.name is None assert actor.name is None
assert actor.summary is None assert actor.summary is None
# this activity shouldn't be deleted
assert actor.outbox_activities.filter(type="Delete").count() == 1

View File

@ -99,6 +99,8 @@ export default {
return {name: 'library.tracks.detail', params: {id: this.objInfo.id}} return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
case 'upload': case 'upload':
return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}} return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
case 'channel':
return {name: 'channels.detail', params: {id: this.objInfo.uuid}}
default: default:
break; break;
@ -147,7 +149,6 @@ export default {
return return
} }
if (this.standalone) { if (this.standalone) {
console.log('HELLO')
this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}}) this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
} }
this.fetch = null this.fetch = null

View File

@ -23,9 +23,9 @@
:translate-params="{count: totalTracks}"> :translate-params="{count: totalTracks}">
%{ count } episode %{ count } episode
</translate> </translate>
<template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)"> </template>
· <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate> <template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
</template> · <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
</template> </template>
<div class="ui hidden small divider"></div> <div class="ui hidden small divider"></div>
<a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button"> <a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button">