diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py index 8c9fe51494418b848d8b4eb513a8c6829089cfaf..7b4b5e169b0546c21d4704d24aa16dfadfe2720f 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -1,6 +1,7 @@ from django import urls from funkwhale_api.audio import spa_views as audio_spa_views +from funkwhale_api.federation import spa_views as federation_spa_views from funkwhale_api.music import spa_views @@ -36,4 +37,9 @@ urlpatterns = [ audio_spa_views.channel_detail_username, name="channel_detail", ), + urls.re_path( + r"^@(?P<username>[^/]+)/?$", + federation_spa_views.actor_detail_username, + name="actor_detail", + ), ] diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py index 30811383628a08a8aa62f0ff431b6561fdc3aec6..bdf700f786fdc47d18c7b015f10a7783e72ed500 100644 --- a/api/funkwhale_api/audio/models.py +++ b/api/funkwhale_api/audio/models.py @@ -64,6 +64,10 @@ class Channel(models.Model): ) ) + @property + def fid(self): + return self.actor.fid + def generate_actor(username, **kwargs): actor_data = user_models.get_actor_data(username, **kwargs) diff --git a/api/funkwhale_api/audio/spa_views.py b/api/funkwhale_api/audio/spa_views.py index c76669d39ece58f31983d89deaaf2be35ed92184..32dc7f585a5a8614e180c537733fd93a580f7e75 100644 --- a/api/funkwhale_api/audio/spa_views.py +++ b/api/funkwhale_api/audio/spa_views.py @@ -7,6 +7,7 @@ from django.urls import reverse from rest_framework import serializers from funkwhale_api.common import preferences +from funkwhale_api.common import middleware from funkwhale_api.common import utils from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import spa_views @@ -14,7 +15,7 @@ from funkwhale_api.music import spa_views from . import models -def channel_detail(query): +def channel_detail(query, redirect_to_ap): queryset = models.Channel.objects.filter(query).select_related( "artist__attachment_cover", "actor", "library" ) @@ -23,6 +24,9 @@ def channel_detail(query): except models.Channel.DoesNotExist: return [] + if redirect_to_ap: + raise middleware.ApiRedirect(obj.actor.fid) + obj_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse( @@ -81,16 +85,16 @@ def channel_detail(query): return metas -def channel_detail_uuid(request, uuid): +def channel_detail_uuid(request, uuid, redirect_to_ap): validator = serializers.UUIDField().to_internal_value try: uuid = validator(uuid) except serializers.ValidationError: return [] - return channel_detail(Q(uuid=uuid)) + return channel_detail(Q(uuid=uuid), redirect_to_ap) -def channel_detail_username(request, username): +def channel_detail_username(request, username, redirect_to_ap): validator = federation_utils.get_actor_data_from_username try: username_data = validator(username) @@ -100,4 +104,4 @@ def channel_detail_username(request, username): actor__domain=username_data["domain"], actor__preferred_username__iexact=username_data["username"], ) - return channel_detail(query) + return channel_detail(query, redirect_to_ap) diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py index 6122adbcab26c91a0657494a8c2ee7a25db55831..201cd2ec84b29c433c62ec10553fa3f8c3487be0 100644 --- a/api/funkwhale_api/common/middleware.py +++ b/api/funkwhale_api/common/middleware.py @@ -4,6 +4,7 @@ import io import os import re import time +import urllib.parse import xml.sax.saxutils from django import http @@ -163,8 +164,16 @@ def render_tags(tags): def get_request_head_tags(request): + accept_header = request.headers.get("Accept") or None + redirect_to_ap = ( + False + if not accept_header + else not federation_utils.should_redirect_ap_to_html(accept_header) + ) match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF) - return match.func(request, *match.args, **match.kwargs) + return match.func( + request, *match.args, redirect_to_ap=redirect_to_ap, **match.kwargs + ) def get_custom_css(): @@ -175,6 +184,30 @@ def get_custom_css(): return xml.sax.saxutils.escape(css) +class ApiRedirect(Exception): + def __init__(self, url): + self.url = url + + +def get_api_response(request, url): + """ + Quite ugly but we have no choice. When Accept header is set to application/activity+json + some clients expect to get a JSON payload (instead of the HTML we return). Since + redirecting to the URL does not work (because it makes the signature verification fail), + we grab the internal view corresponding to the URL, call it and return this as the + response + """ + path = urllib.parse.urlparse(url).path + + try: + match = urls.resolve(path) + except urls.exceptions.Resolver404: + return http.HttpResponseNotFound() + response = match.func(request, *match.args, **match.kwargs) + response.render() + return response + + class SPAFallbackMiddleware: def __init__(self, get_response): self.get_response = get_response @@ -183,7 +216,10 @@ class SPAFallbackMiddleware: response = self.get_response(request) if response.status_code == 404 and should_fallback_to_spa(request.path): - return serve_spa(request) + try: + return serve_spa(request) + except ApiRedirect as e: + return get_api_response(request, e.url) return response diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index b8d04164c120a50b31ced79a4b71116cd705e397..7d9d25a2de0c31dddddf9c6dc6dd7c0545b69ced 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -165,14 +165,14 @@ def receive(activity, on_behalf_of, inbox_actor=None): return local_to_recipients = get_actors_from_audience(activity.get("to", [])) - local_to_recipients = local_to_recipients.exclude(user=None) + local_to_recipients = local_to_recipients.local() local_to_recipients = local_to_recipients.values_list("pk", flat=True) local_to_recipients = list(local_to_recipients) if inbox_actor: local_to_recipients.append(inbox_actor.pk) local_cc_recipients = get_actors_from_audience(activity.get("cc", [])) - local_cc_recipients = local_cc_recipients.exclude(user=None) + local_cc_recipients = local_cc_recipients.local() local_cc_recipients = local_cc_recipients.values_list("pk", flat=True) inbox_items = [] @@ -457,6 +457,13 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non else: remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url) urls.append(r["target"].followers_url) + elif isinstance(r, dict) and r["type"] == "actor_inbox": + actor = r["actor"] + urls.append(actor.fid) + if actor.is_local: + local_recipients.add(actor) + else: + remote_inbox_urls.add(actor.inbox_url) elif isinstance(r, dict) and r["type"] == "instances_with_followers": # we want to broadcast the activity to other instances service actors diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py index b3fc112f0e0c233595ba9a63d4af9bf6bee73d2a..3e61c03fb0c7e037a8cc4b7dd9de07b1ecf2542b 100644 --- a/api/funkwhale_api/federation/contexts.py +++ b/api/funkwhale_api/federation/contexts.py @@ -301,6 +301,38 @@ CONTEXTS = [ } }, }, + { + "shortId": "LITEPUB", + "contextUrl": None, + "documentUrl": "http://litepub.social/ns", + "document": { + # from https://ap.thequietplace.social/schemas/litepub-0.1.jsonld + "@context": { + "Emoji": "toot:Emoji", + "Hashtag": "as:Hashtag", + "PropertyValue": "schema:PropertyValue", + "atomUri": "ostatus:atomUri", + "conversation": {"@id": "ostatus:conversation", "@type": "@id"}, + "discoverable": "toot:discoverable", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "ostatus": "http://ostatus.org#", + "schema": "http://schema.org#", + "toot": "http://joinmastodon.org/ns#", + "value": "schema:value", + "sensitive": "as:sensitive", + "litepub": "http://litepub.social/ns#", + "invisible": "litepub:invisible", + "directMessage": "litepub:directMessage", + "listMessage": {"@id": "litepub:listMessage", "@type": "@id"}, + "oauthRegistrationEndpoint": { + "@id": "litepub:oauthRegistrationEndpoint", + "@type": "@id", + }, + "EmojiReact": "litepub:EmojiReact", + "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"}, + } + }, + }, ] CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS} @@ -332,3 +364,4 @@ AS = NS(CONTEXTS_BY_ID["AS"]) LDP = NS(CONTEXTS_BY_ID["LDP"]) SEC = NS(CONTEXTS_BY_ID["SEC"]) FW = NS(CONTEXTS_BY_ID["FW"]) +LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"]) diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index e91d8dac994a458e2792480aa34274afc4e445aa..97158582d089bf3b085ab14b7b02da6a0b2411b5 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -125,7 +125,8 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): self.domain = models.Domain.objects.get_or_create( name=settings.FEDERATION_HOSTNAME )[0] - self.save(update_fields=["domain"]) + self.fid = "https://{}/actors/{}".format(self.domain, self.preferred_username) + self.save(update_fields=["domain", "fid"]) if not create: if extracted and hasattr(extracted, "pk"): extracted.actor = self @@ -166,7 +167,9 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): model = "music.Library" class Params: - local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True)) + local = factory.Trait( + fid=None, actor=factory.SubFactory(ActorFactory, local=True) + ) @registry.register diff --git a/api/funkwhale_api/federation/jsonld.py b/api/funkwhale_api/federation/jsonld.py index 450490c910ff072927c1b2311aa9b45f72700359..05a4386062bc448e2e2c1b381dfac81d1f297865 100644 --- a/api/funkwhale_api/federation/jsonld.py +++ b/api/funkwhale_api/federation/jsonld.py @@ -17,6 +17,10 @@ def cached_contexts(loader): for cached in contexts.CONTEXTS: if url == cached["documentUrl"]: return cached + if cached["shortId"] == "LITEPUB" and "/schemas/litepub-" in url: + # XXX UGLY fix for pleroma because they host their schema + # under each instance domain, which makes caching harder + return cached return loader(url, *args, **kwargs) return load @@ -29,18 +33,19 @@ def get_document_loader(): return cached_contexts(loader) -def expand(doc, options=None, insert_fw_context=True): +def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]): options = options or {} options.setdefault("documentLoader", get_document_loader()) if isinstance(doc, str): doc = options["documentLoader"](doc)["document"] - if insert_fw_context: - fw = contexts.CONTEXTS_BY_ID["FW"]["documentUrl"] + for context_name in default_contexts: + ctx = contexts.CONTEXTS_BY_ID[context_name]["documentUrl"] try: - insert_context(fw, doc) + insert_context(ctx, doc) except KeyError: # probably an already expanded document pass + result = pyld.jsonld.expand(doc, options=options) try: # jsonld.expand returns a list, which is useless for us diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 9514f203a8051c5d7920e6feab59048df13d06b6..3579369557f6a08cb37de1b40f471ec7429d1ed8 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -443,26 +443,29 @@ class Activity(models.Model): type = models.CharField(db_index=True, null=True, max_length=100) # generic relations - object_id = models.IntegerField(null=True) + object_id = models.IntegerField(null=True, blank=True) object_content_type = models.ForeignKey( ContentType, null=True, + blank=True, on_delete=models.SET_NULL, related_name="objecting_activities", ) object = GenericForeignKey("object_content_type", "object_id") - target_id = models.IntegerField(null=True) + target_id = models.IntegerField(null=True, blank=True) target_content_type = models.ForeignKey( ContentType, null=True, + blank=True, on_delete=models.SET_NULL, related_name="targeting_activities", ) target = GenericForeignKey("target_content_type", "target_id") - related_object_id = models.IntegerField(null=True) + related_object_id = models.IntegerField(null=True, blank=True) related_object_content_type = models.ForeignKey( ContentType, null=True, + blank=True, on_delete=models.SET_NULL, related_name="related_objecting_activities", ) diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 32a8357dba613f36aafc0ef7ee5c17505fbb9310..70f312d1a644fdbed58affd65ea18c64227ae145 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -451,3 +451,35 @@ def inbox_delete_actor(payload, context): logger.warn("Cannot delete actor %s, no matching object found", actor.fid) return actor.delete() + + +@inbox.register({"type": "Flag"}) +def inbox_flag(payload, context): + serializer = serializers.FlagSerializer(data=payload, context=context) + if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): + logger.debug( + "Discarding invalid report from {}: %s", + context["actor"].fid, + serializer.errors, + ) + return + + report = serializer.save() + return {"object": report.target, "related_object": report} + + +@outbox.register({"type": "Flag"}) +def outbox_flag(context): + report = context["report"] + actor = actors.get_service_actor() + serializer = serializers.FlagSerializer(report) + yield { + "type": "Flag", + "actor": actor, + "payload": with_recipients( + serializer.data, + # Mastodon requires the report to be sent to the reported actor inbox + # (and not the shared inbox) + to=[{"type": "actor_inbox", "actor": report.target_owner}], + ), + } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 2adbcbec40252ebd004d781a0cee79f38b3d4b72..08b51a6302c9e59c6bf83b412b857252de884507 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -2,6 +2,7 @@ import logging import urllib.parse import uuid +from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db import transaction @@ -9,6 +10,9 @@ 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 from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks @@ -36,13 +40,20 @@ class MediaSerializer(jsonld.JsonLdSerializer): def __init__(self, *args, **kwargs): self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) + self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False) super().__init__(*args, **kwargs) + self.fields["mediaType"].required = not self.allow_empty_mimetype + self.fields["mediaType"].allow_null = self.allow_empty_mimetype def validate_mediaType(self, v): if not self.allowed_mimetypes: # no restrictions return v + if self.allow_empty_mimetype and not v: + return None + for mt in self.allowed_mimetypes: + if mt.endswith("/*"): if v.startswith(mt.replace("*", "")): return v @@ -147,7 +158,10 @@ class ActorSerializer(jsonld.JsonLdSerializer): publicKey = PublicKeySerializer(required=False) endpoints = EndpointsSerializer(required=False) icon = ImageSerializer( - allowed_mimetypes=["image/*"], allow_null=True, required=False + allowed_mimetypes=["image/*"], + allow_null=True, + required=False, + allow_empty_mimetype=True, ) class Meta: @@ -294,7 +308,7 @@ class ActorSerializer(jsonld.JsonLdSerializer): common_utils.attach_file( actor, "attachment_icon", - {"url": new_value["url"], "mimetype": new_value["mediaType"]} + {"url": new_value["url"], "mimetype": new_value.get("mediaType")} if new_value else None, ) @@ -1030,7 +1044,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): return validated_data # create the attachment by hand so it can be attached as the cover validated_data["attachment_cover"] = common_models.Attachment.objects.create( - mimetype=attachment_cover["mediaType"], + mimetype=attachment_cover.get("mediaType"), url=attachment_cover["url"], actor=instance.attributed_to, ) @@ -1048,7 +1062,10 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): class ArtistSerializer(MusicEntitySerializer): image = ImageSerializer( - allowed_mimetypes=["image/*"], allow_null=True, required=False + allowed_mimetypes=["image/*"], + allow_null=True, + required=False, + allow_empty_mimetype=True, ) updateable_fields = [ ("name", "name"), @@ -1094,7 +1111,10 @@ class AlbumSerializer(MusicEntitySerializer): artists = serializers.ListField(child=ArtistSerializer(), min_length=1) # XXX: 1.0 rename to image cover = ImageSerializer( - allowed_mimetypes=["image/*"], allow_null=True, required=False + allowed_mimetypes=["image/*"], + allow_null=True, + required=False, + allow_empty_mimetype=True, ) updateable_fields = [ ("name", "title"), @@ -1172,7 +1192,10 @@ class TrackSerializer(MusicEntitySerializer): license = serializers.URLField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False) image = ImageSerializer( - allowed_mimetypes=["image/*"], allow_null=True, required=False + allowed_mimetypes=["image/*"], + allow_null=True, + required=False, + allow_empty_mimetype=True, ) updateable_fields = [ @@ -1437,6 +1460,85 @@ class ActorDeleteSerializer(jsonld.JsonLdSerializer): jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)} +class FlagSerializer(jsonld.JsonLdSerializer): + type = serializers.ChoiceField(choices=[contexts.AS.Flag]) + id = serializers.URLField(max_length=500) + object = serializers.URLField(max_length=500) + content = serializers.CharField(required=False, allow_null=True, allow_blank=True) + actor = serializers.URLField(max_length=500) + type = serializers.ListField( + child=TagSerializer(), min_length=0, required=False, allow_null=True + ) + + class Meta: + jsonld_mapping = { + "object": jsonld.first_id(contexts.AS.object), + "content": jsonld.first_val(contexts.AS.content), + "actor": jsonld.first_id(contexts.AS.actor), + "type": jsonld.raw(contexts.AS.tag), + } + + def validate_object(self, v): + try: + return utils.get_object_by_fid(v, local=True) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Unknown id {} for reported object".format(v) + ) + + def validate_type(self, tags): + if tags: + for tag in tags: + if tag["name"] in dict(moderation_models.REPORT_TYPES): + return tag["name"] + return "other" + + def validate_actor(self, v): + try: + return models.Actor.objects.get(fid=v, domain=self.context["actor"].domain) + except models.Actor.DoesNotExist: + raise serializers.ValidationError("Invalid actor") + + def validate(self, data): + validated_data = super().validate(data) + + return validated_data + + def create(self, validated_data): + kwargs = { + "target": validated_data["object"], + "target_owner": moderation_serializers.get_target_owner( + validated_data["object"] + ), + "target_state": moderation_serializers.get_target_state( + validated_data["object"] + ), + "type": validated_data.get("type", "other"), + "summary": validated_data.get("content"), + "submitter": validated_data["actor"], + } + + report, created = moderation_models.Report.objects.update_or_create( + fid=validated_data["id"], defaults=kwargs, + ) + moderation_signals.report_created.send(sender=None, report=report) + return report + + def to_representation(self, instance): + d = { + "type": "Flag", + "id": instance.get_federation_id(), + "actor": actors.get_service_actor().fid, + "object": [instance.target.fid], + "content": instance.summary, + "tag": [repr_tag(instance.type)], + } + + if self.context.get("include_ap_context", self.parent is None): + d["@context"] = jsonld.get_default_context() + return d + + class NodeInfoLinkSerializer(serializers.Serializer): href = serializers.URLField() rel = serializers.URLField() diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py index 0d922d3258733ac431ee15855a21537a8217faa8..b69c486682bf06cbcba520ecf2c62d8c662e0d84 100644 --- a/api/funkwhale_api/federation/signing.py +++ b/api/funkwhale_api/federation/signing.py @@ -1,3 +1,4 @@ +import cryptography.exceptions import datetime import logging import pytz @@ -31,18 +32,29 @@ def verify_date(raw_date): now = timezone.now() if dt < now - delta or dt > now + delta: raise forms.ValidationError( - "Request Date is too far in the future or in the past" + "Request Date {} is too far in the future or in the past".format(raw_date) ) return dt def verify(request, public_key): - verify_date(request.headers.get("Date")) - - return requests_http_signature.HTTPSignatureAuth.verify( - request, key_resolver=lambda **kwargs: public_key, use_auth_header=False + date = request.headers.get("Date") + logger.debug( + "Verifying request with date %s and headers %s", date, str(request.headers) ) + verify_date(date) + try: + return requests_http_signature.HTTPSignatureAuth.verify( + request, key_resolver=lambda **kwargs: public_key, use_auth_header=False + ) + except cryptography.exceptions.InvalidSignature: + logger.warning( + "Could not verify request with date %s and headers %s", + date, + str(request.headers), + ) + raise def verify_django(django_request, public_key): diff --git a/api/funkwhale_api/federation/spa_views.py b/api/funkwhale_api/federation/spa_views.py new file mode 100644 index 0000000000000000000000000000000000000000..af7e210cf996c2ee56d56bb39807d79f5455bf72 --- /dev/null +++ b/api/funkwhale_api/federation/spa_views.py @@ -0,0 +1,63 @@ +from django.conf import settings + +from rest_framework import serializers + +from funkwhale_api.common import preferences +from funkwhale_api.common import middleware +from funkwhale_api.common import utils +from funkwhale_api.federation import utils as federation_utils + +from . import models + + +def actor_detail_username(request, username, redirect_to_ap): + validator = federation_utils.get_actor_data_from_username + try: + username_data = validator(username) + except serializers.ValidationError: + return [] + + queryset = ( + models.Actor.objects.filter( + preferred_username__iexact=username_data["username"] + ) + .local() + .select_related("attachment_icon") + ) + try: + obj = queryset.get() + except models.Actor.DoesNotExist: + return [] + + if redirect_to_ap: + raise middleware.ApiRedirect(obj.fid) + obj_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("actor_detail", kwargs={"username": obj.preferred_username}), + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": obj_url}, + {"tag": "meta", "property": "og:title", "content": obj.display_name}, + {"tag": "meta", "property": "og:type", "content": "profile"}, + ] + + if obj.attachment_icon: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": obj.attachment_icon.download_url_medium_square_crop, + } + ) + + if preferences.get("federation__enabled"): + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": obj.fid, + } + ) + + return metas diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index 8070b1310306acf04d2902e9aa6363b5895d66a9..b4f63e680e0042102d5372ca703e1e1bd23a6018 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -1,8 +1,12 @@ import html.parser import unicodedata +import urllib.parse import re + +from django.apps import apps from django.conf import settings -from django.db.models import Q +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import CharField, Q, Value from funkwhale_api.common import session from funkwhale_api.moderation import mrf @@ -203,7 +207,7 @@ def find_alternate(response_text): return parser.result -def should_redirect_ap_to_html(accept_header): +def should_redirect_ap_to_html(accept_header, default=True): if not accept_header: return False @@ -223,4 +227,43 @@ def should_redirect_ap_to_html(accept_header): if ct in no_redirect_headers: return False - return True + return default + + +FID_MODEL_LABELS = [ + "music.Artist", + "music.Album", + "music.Track", + "music.Library", + "music.Upload", + "federation.Actor", +] + + +def get_object_by_fid(fid, local=None): + + if local is True: + parsed = urllib.parse.urlparse(fid) + if parsed.netloc != settings.FEDERATION_HOSTNAME: + raise ObjectDoesNotExist() + + models = [apps.get_model(*l.split(".")) for l in FID_MODEL_LABELS] + + def get_qs(model): + return ( + model.objects.all() + .filter(fid=fid) + .annotate(__type=Value(model._meta.label, output_field=CharField())) + .values("fid", "__type") + ) + + qs = get_qs(models[0]) + for m in models[1:]: + qs = qs.union(get_qs(m)) + + result = qs.order_by("fid").first() + + if not result: + raise ObjectDoesNotExist() + + return apps.get_model(*result["__type"].split(".")).objects.get(fid=fid) diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py index b426a6cea2a4d36ceca75aa56726c5a9f75c6a0d..96e6b5d1169bd57b627e13a0e8dc45a3715977c1 100644 --- a/api/funkwhale_api/moderation/factories.py +++ b/api/funkwhale_api/moderation/factories.py @@ -63,6 +63,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class Params: anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) + local = factory.Trait(fid=None) assigned = factory.Trait( assigned_to=factory.SubFactory(federation_factories.ActorFactory) ) diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index 81e5846bb4a9c5c3e887a1a6a0cb9a92bd36b321..7d772d39e1d4a6de5318b94c2323a441249f6af3 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -194,6 +194,27 @@ TARGET_CONFIG = { TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG) +def get_target_state(target): + state = {} + target_state_serializer = state_serializers[target._meta.label] + + state = target_state_serializer(target).data + # freeze target type/id in JSON so even if the corresponding object is deleted + # we can have the info and display it in the frontend + target_data = TARGET_FIELD.to_representation(target) + state["_target"] = json.loads(json.dumps(target_data, cls=DjangoJSONEncoder)) + + if "fid" in state: + state["domain"] = urllib.parse.urlparse(state["fid"]).hostname + + state["is_local"] = ( + state.get("domain", settings.FEDERATION_HOSTNAME) + == settings.FEDERATION_HOSTNAME + ) + + return state + + class ReportSerializer(serializers.ModelSerializer): target = TARGET_FIELD @@ -234,29 +255,7 @@ class ReportSerializer(serializers.ModelSerializer): return validated_data def create(self, validated_data): - target_state_serializer = state_serializers[ - validated_data["target"]._meta.label - ] - - validated_data["target_state"] = target_state_serializer( - validated_data["target"] - ).data - # freeze target type/id in JSON so even if the corresponding object is deleted - # we can have the info and display it in the frontend - target_data = self.fields["target"].to_representation(validated_data["target"]) - validated_data["target_state"]["_target"] = json.loads( - json.dumps(target_data, cls=DjangoJSONEncoder) - ) - - if "fid" in validated_data["target_state"]: - validated_data["target_state"]["domain"] = urllib.parse.urlparse( - validated_data["target_state"]["fid"] - ).hostname - - validated_data["target_state"]["is_local"] = ( - validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME) - == settings.FEDERATION_HOSTNAME - ) + validated_data["target_state"] = get_target_state(validated_data["target"]) validated_data["target_owner"] = get_target_owner(validated_data["target"]) r = super().create(validated_data) tasks.signals.report_created.send(sender=None, report=r) diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py index 67de68001d507ce688ae20bba33b050fdc08e026..b3a91594a402dad40735d3a5f4b2f0097eae0fe9 100644 --- a/api/funkwhale_api/moderation/views.py +++ b/api/funkwhale_api/moderation/views.py @@ -5,6 +5,9 @@ from rest_framework import response from rest_framework import status from rest_framework import viewsets +from funkwhale_api.federation import routes +from funkwhale_api.federation import utils as federation_utils + from . import models from . import serializers @@ -66,4 +69,13 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): submitter = None if self.request.user.is_authenticated: submitter = self.request.user.actor - serializer.save(submitter=submitter) + report = serializer.save(submitter=submitter) + forward = self.request.data.get("forward", False) + if ( + forward + and report.target + and report.target_owner + and hasattr(report.target, "fid") + and not federation_utils.is_local(report.target.fid) + ): + routes.outbox.dispatch({"type": "Flag"}, context={"report": report}) diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 073f5bb965d4391930d5358327fc80d68a02c77b..7997a3c07a89a68073a2d62dddfadf80240f4f6a 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -5,6 +5,7 @@ from django.urls import reverse from django.db.models import Q from funkwhale_api.common import preferences +from funkwhale_api.common import middleware from funkwhale_api.common import utils from funkwhale_api.playlists import models as playlists_models @@ -25,12 +26,16 @@ def get_twitter_card_metas(type, id): ] -def library_track(request, pk): +def library_track(request, pk, redirect_to_ap): queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist") try: obj = queryset.get() except models.Track.DoesNotExist: return [] + + if redirect_to_ap: + raise middleware.ApiRedirect(obj.fid) + track_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_track", kwargs={"pk": obj.pk}), @@ -114,12 +119,16 @@ def library_track(request, pk): return metas -def library_album(request, pk): +def library_album(request, pk, redirect_to_ap): queryset = models.Album.objects.filter(pk=pk).select_related("artist") try: obj = queryset.get() except models.Album.DoesNotExist: return [] + + if redirect_to_ap: + raise middleware.ApiRedirect(obj.fid) + album_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_album", kwargs={"pk": obj.pk}), @@ -182,12 +191,16 @@ def library_album(request, pk): return metas -def library_artist(request, pk): +def library_artist(request, pk, redirect_to_ap): queryset = models.Artist.objects.filter(pk=pk) try: obj = queryset.get() except models.Artist.DoesNotExist: return [] + + if redirect_to_ap: + raise middleware.ApiRedirect(obj.fid) + artist_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}), @@ -242,7 +255,7 @@ def library_artist(request, pk): return metas -def library_playlist(request, pk): +def library_playlist(request, pk, redirect_to_ap): queryset = playlists_models.Playlist.objects.filter(pk=pk, privacy_level="everyone") try: obj = queryset.get() @@ -294,12 +307,16 @@ def library_playlist(request, pk): return metas -def library_library(request, uuid): +def library_library(request, uuid, redirect_to_ap): queryset = models.Library.objects.filter(uuid=uuid) try: obj = queryset.get() except models.Library.DoesNotExist: return [] + + if redirect_to_ap: + raise middleware.ApiRedirect(obj.fid) + library_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}), diff --git a/api/setup.cfg b/api/setup.cfg index f50bd547391841695945d2c6f2d5fd1f3fb5e077..581396c37acad5639b67a8fade0e5de36fe7b58c 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -18,7 +18,11 @@ env = EMAIL_CONFIG=consolemail:// CELERY_BROKER_URL=memory:// CELERY_TASK_ALWAYS_EAGER=True + FUNKWHALE_HOSTNAME_SUFFIX= + FUNKWHALE_HOSTNAME_PREFIX= + FUNKWHALE_HOSTNAME=test.federation FEDERATION_HOSTNAME=test.federation + FUNKWHALE_URL=https://test.federation DEBUG_TOOLBAR_ENABLED=False DEBUG=False WEAK_PASSWORDS=True diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py index d3908e3f9f45d3417f634cfd94fb3138807a4df5..88e8d05848fa83e1052af1be5672dbc95f8ff617 100644 --- a/api/tests/common/test_middleware.py +++ b/api/tests/common/test_middleware.py @@ -8,6 +8,7 @@ from funkwhale_api.federation import utils as federation_utils from funkwhale_api.common import middleware from funkwhale_api.common import throttling +from funkwhale_api.common import utils def test_spa_fallback_middleware_no_404(mocker): @@ -142,11 +143,11 @@ def test_get_spa_html_from_disk(tmp_path): def test_get_route_head_tags(mocker, settings): match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock()) resolve = mocker.patch("django.urls.resolve", return_value=match) - request = mocker.Mock(path="/tracks/42") + request = mocker.Mock(path="/tracks/42", headers={}) tags = middleware.get_request_head_tags(request) assert tags == match.func.return_value - match.func.assert_called_once_with(request, *[], **{"pk": 42}) + match.func.assert_called_once_with(request, *[], redirect_to_ap=False, **{"pk": 42}) resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF) @@ -326,3 +327,90 @@ def test_rewrite_manifest_json_url_rewrite_default_url(mocker, settings): expected_url ) assert response.content == expected_html.encode() + + +def test_spa_middleware_handles_api_redirect(mocker): + get_response = mocker.Mock(return_value=mocker.Mock(status_code=404)) + redirect_url = "/test" + mocker.patch.object( + middleware, "serve_spa", side_effect=middleware.ApiRedirect(redirect_url) + ) + api_view = mocker.Mock() + match = mocker.Mock(args=["hello"], kwargs={"foo": "bar"}, func=api_view) + mocker.patch.object(middleware.urls, "resolve", return_value=match) + + request = mocker.Mock(path="/") + + m = middleware.SPAFallbackMiddleware(get_response) + + response = m(request) + + api_view.assert_called_once_with(request, "hello", foo="bar") + assert response == api_view.return_value + + +@pytest.mark.parametrize( + "accept_header, expected", + [ + ("text/html", False), + ("application/activity+json", True), + ("", False), + ("noop", False), + ("text/html,application/activity+json", False), + ("application/activity+json,text/html", True), + ], +) +def test_get_request_head_tags_calls_view_with_proper_arg_when_accept_header_set( + accept_header, expected, mocker, fake_request +): + request = fake_request.get("/", HTTP_ACCEPT=accept_header) + + view = mocker.Mock() + match = mocker.Mock(args=["hello"], kwargs={"foo": "bar"}, func=view) + mocker.patch.object(middleware.urls, "resolve", return_value=match) + + assert middleware.get_request_head_tags(request) == view.return_value + view.assert_called_once_with(request, "hello", foo="bar", redirect_to_ap=expected) + + +@pytest.mark.parametrize( + "factory_name, factory_kwargs, route_name, route_arg_name, route_arg", + [ + ( + "federation.Actor", + {"local": True}, + "actor_detail", + "username", + "preferred_username", + ), + ( + "audio.Channel", + {"local": True}, + "channel_detail", + "username", + "actor.preferred_username", + ), + ("music.Artist", {}, "library_artist", "pk", "pk",), + ("music.Album", {}, "library_album", "pk", "pk",), + ("music.Track", {}, "library_track", "pk", "pk",), + ("music.Library", {}, "library_library", "uuid", "uuid",), + ], +) +def test_spa_views_raise_api_redirect_when_accept_json_set( + factory_name, + factory_kwargs, + route_name, + route_arg_name, + route_arg, + factories, + fake_request, +): + obj = factories[factory_name](**factory_kwargs) + url = utils.spa_reverse( + route_name, kwargs={route_arg_name: utils.recursive_getattr(obj, route_arg)} + ) + request = fake_request.get(url, HTTP_ACCEPT="application/activity+json") + + with pytest.raises(middleware.ApiRedirect) as excinfo: + middleware.get_request_head_tags(request) + assert excinfo.value.url == obj.fid diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index bac27efc542bf085a699461b14e8892acf29a009..89a25b22a19d004939376078bb71fe17914bc2ed 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -456,6 +456,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): shared_inbox_url=remote_actor1.shared_inbox_url ) remote_actor3 = factories["federation.Actor"](shared_inbox_url=None) + remote_actor4 = factories["federation.Actor"]() library = factories["music.Library"]() library_follower_local = factories["federation.LibraryFollow"]( @@ -491,6 +492,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): activity.PUBLIC_ADDRESS, {"type": "followers", "target": library}, {"type": "followers", "target": followed_actor}, + {"type": "actor_inbox", "actor": remote_actor4}, ] inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items( @@ -511,6 +513,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): [ models.Delivery(inbox_url=remote_actor1.shared_inbox_url), models.Delivery(inbox_url=remote_actor3.inbox_url), + models.Delivery(inbox_url=remote_actor4.inbox_url), models.Delivery(inbox_url=library_follower_remote.inbox_url), models.Delivery(inbox_url=actor_follower_remote.inbox_url), ], @@ -527,6 +530,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): activity.PUBLIC_ADDRESS, library.followers_url, followed_actor.followers_url, + remote_actor4.fid, ] assert urls == expected_urls diff --git a/api/tests/federation/test_jsonld.py b/api/tests/federation/test_jsonld.py index 7bf906d50ca690e1c71818c136326b1e9866eb1c..92253f921407018afc319d84f31b88c3c8f31da6 100644 --- a/api/tests/federation/test_jsonld.py +++ b/api/tests/federation/test_jsonld.py @@ -67,6 +67,95 @@ def test_expand_no_external_request(): assert doc == expected +def test_expand_no_external_request_pleroma(): + payload = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://pleroma.example/schemas/litepub-0.1.jsonld", + {"@language": "und"}, + ], + "endpoints": { + "oauthAuthorizationEndpoint": "https://pleroma.example/oauth/authorize", + "oauthRegistrationEndpoint": "https://pleroma.example/api/v1/apps", + "oauthTokenEndpoint": "https://pleroma.example/oauth/token", + "sharedInbox": "https://pleroma.example/inbox", + "uploadMedia": "https://pleroma.example/api/ap/upload_media", + }, + "followers": "https://pleroma.example/internal/fetch/followers", + "following": "https://pleroma.example/internal/fetch/following", + "id": "https://pleroma.example/internal/fetch", + "inbox": "https://pleroma.example/internal/fetch/inbox", + "invisible": True, + "manuallyApprovesFollowers": False, + "name": "Pleroma", + "preferredUsername": "internal.fetch", + "publicKey": { + "id": "https://pleroma.example/internal/fetch#main-key", + "owner": "https://pleroma.example/internal/fetch", + "publicKeyPem": "PEM", + }, + "summary": "An internal service actor for this Pleroma instance. No user-serviceable parts inside.", + "type": "Application", + "url": "https://pleroma.example/internal/fetch", + } + + expected = { + contexts.AS.endpoints: [ + { + contexts.AS.sharedInbox: [{"@id": "https://pleroma.example/inbox"}], + contexts.AS.oauthAuthorizationEndpoint: [ + {"@id": "https://pleroma.example/oauth/authorize"} + ], + contexts.LITEPUB.oauthRegistrationEndpoint: [ + {"@id": "https://pleroma.example/api/v1/apps"} + ], + contexts.AS.oauthTokenEndpoint: [ + {"@id": "https://pleroma.example/oauth/token"} + ], + contexts.AS.uploadMedia: [ + {"@id": "https://pleroma.example/api/ap/upload_media"} + ], + }, + ], + contexts.AS.followers: [ + {"@id": "https://pleroma.example/internal/fetch/followers"} + ], + contexts.AS.following: [ + {"@id": "https://pleroma.example/internal/fetch/following"} + ], + "@id": "https://pleroma.example/internal/fetch", + "http://www.w3.org/ns/ldp#inbox": [ + {"@id": "https://pleroma.example/internal/fetch/inbox"} + ], + contexts.LITEPUB.invisible: [{"@value": True}], + contexts.AS.manuallyApprovesFollowers: [{"@value": False}], + contexts.AS.name: [{"@language": "und", "@value": "Pleroma"}], + contexts.AS.summary: [ + { + "@language": "und", + "@value": "An internal service actor for this Pleroma instance. No user-serviceable parts inside.", + } + ], + contexts.AS.url: [{"@id": "https://pleroma.example/internal/fetch"}], + contexts.AS.preferredUsername: [ + {"@language": "und", "@value": "internal.fetch"} + ], + contexts.SEC.publicKey: [ + { + "@id": "https://pleroma.example/internal/fetch#main-key", + contexts.SEC.owner: [{"@id": "https://pleroma.example/internal/fetch"}], + contexts.SEC.publicKeyPem: [{"@language": "und", "@value": "PEM"}], + } + ], + "@type": [contexts.AS.Application], + } + + doc = jsonld.expand(payload) + + assert doc[contexts.AS.endpoints] == expected[contexts.AS.endpoints] + assert doc == expected + + def test_expand_remote_doc(r_mock): url = "https://noop/federation/actors/demo" payload = { diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 587ccc33323d9135ed9d5918c89d4dd2ccffb79e..f63f82896d4559ae20e12088d70e5eabd1075672 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -8,6 +8,7 @@ from funkwhale_api.federation import ( routes, serializers, ) +from funkwhale_api.moderation import serializers as moderation_serializers @pytest.mark.parametrize( @@ -30,6 +31,7 @@ from funkwhale_api.federation import ( ({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album), ({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track), ({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor), + ({"type": "Flag"}, routes.inbox_flag), ], ) def test_inbox_routes(route, handler): @@ -44,6 +46,7 @@ def test_inbox_routes(route, handler): "route,handler", [ ({"type": "Accept"}, routes.outbox_accept), + ({"type": "Flag"}, routes.outbox_flag), ({"type": "Follow"}, routes.outbox_follow), ({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio), ( @@ -718,3 +721,69 @@ def test_inbox_delete_actor_doesnt_delete_local_actor(factories): ) # actor should still be here! local_actor.refresh_from_db() + + +@pytest.mark.parametrize( + "factory_name, factory_kwargs", + [ + ("federation.Actor", {"local": True}), + ("music.Artist", {"local": True}), + ("music.Album", {"local": True}), + ("music.Track", {"local": True}), + ("music.Library", {"local": True}), + ], +) +def test_inbox_flag(factory_name, factory_kwargs, factories, mocker): + report_created_send = mocker.patch( + "funkwhale_api.moderation.signals.report_created.send" + ) + actor = factories["federation.Actor"]() + target = factories[factory_name](**factory_kwargs) + payload = { + "type": "Flag", + "object": [target.fid], + "content": "Test report", + "id": "https://" + actor.domain_id + "/testid", + "actor": actor.fid, + } + serializer = serializers.ActivitySerializer(payload) + + result = routes.inbox_flag( + serializer.data, context={"actor": actor, "raise_exception": True} + ) + + report = actor.reports.latest("id") + + assert result == {"object": target, "related_object": report} + assert report.fid == payload["id"] + assert report.type == "other" + assert report.target == target + assert report.target_owner == moderation_serializers.get_target_owner(target) + assert report.target_state == moderation_serializers.get_target_state(target) + + report_created_send.assert_called_once_with(sender=None, report=report) + + +@pytest.mark.parametrize( + "factory_name, factory_kwargs", + [ + ("federation.Actor", {"local": True}), + ("music.Artist", {"local": True}), + ("music.Album", {"local": True}), + ("music.Track", {"local": True}), + ("music.Library", {"local": True}), + ], +) +def test_outbox_flag(factory_name, factory_kwargs, factories, mocker): + target = factories[factory_name](**factory_kwargs) + report = factories["moderation.Report"]( + target=target, local=True, target_owner=factories["federation.Actor"]() + ) + + activity = list(routes.outbox_flag({"report": report}))[0] + + serializer = serializers.FlagSerializer(report) + expected = serializer.data + expected["to"] = [{"type": "actor_inbox", "actor": report.target_owner}] + assert activity["payload"] == expected + assert activity["actor"] == actors.get_service_actor() diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index f2fd68eb306f79939fa4b4f4360b47fd6f788947..e203e0aff6ebbb00de21bd8d19573fc311e31c56 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -6,12 +6,14 @@ from django.core.paginator import Paginator from django.utils import timezone from funkwhale_api.common import utils as common_utils +from funkwhale_api.federation import actors from funkwhale_api.federation import contexts from funkwhale_api.federation import keys from funkwhale_api.federation import jsonld from funkwhale_api.federation import models from funkwhale_api.federation import serializers from funkwhale_api.federation import utils +from funkwhale_api.moderation import serializers as moderation_serializers from funkwhale_api.music import licenses @@ -70,6 +72,36 @@ def test_actor_serializer_from_ap(db): assert actor.attachment_icon.mimetype == payload["icon"]["mediaType"] +def test_actor_serializer_from_ap_no_icon_mediaType(db): + private, public = keys.get_key_pair() + actor_url = "https://test.federation/actor" + payload = { + "@context": jsonld.get_default_context_fw(), + "id": actor_url, + "type": "Person", + "inbox": "https://test.com/inbox", + "following": "https://test.com/following", + "followers": "https://test.com/followers", + "preferredUsername": "test", + "manuallyApprovesFollowers": True, + "url": "http://hello.world/path", + "publicKey": { + "publicKeyPem": public.decode("utf-8"), + "owner": actor_url, + "id": actor_url + "#main-key", + }, + "endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"}, + "icon": {"type": "Image", "url": "https://image.example/image.png"}, + } + + serializer = serializers.ActorSerializer(data=payload) + assert serializer.is_valid(raise_exception=True) + actor = serializer.save() + + assert actor.attachment_icon.url == payload["icon"]["url"] + assert actor.attachment_icon.mimetype is None + + def test_actor_serializer_only_mandatory_field_from_ap(db): payload = { "@context": jsonld.get_default_context(), @@ -1477,3 +1509,44 @@ def test_channel_create_upload_serializer(factories): serializer = serializers.ChannelCreateUploadSerializer(upload) assert serializer.data == expected + + +def test_report_serializer_from_ap_create(factories, faker, now, mocker): + actor = factories["federation.Actor"]() + obj = factories["music.Artist"](local=True) + payload = { + "@context": jsonld.get_default_context(), + "type": "Flag", + "id": "https://test.report", + "actor": actor.fid, + "content": "hello world", + "object": [obj.fid], + "tag": [{"type": "Hashtag", "name": "#offensive_content"}], + } + serializer = serializers.FlagSerializer(data=payload, context={"actor": actor}) + assert serializer.is_valid(raise_exception=True) is True + + report = serializer.save() + + assert report.fid == payload["id"] + assert report.summary == payload["content"] + assert report.submitter == actor + assert report.target == obj + assert report.target_state == moderation_serializers.get_target_state(obj) + assert report.target_owner == moderation_serializers.get_target_owner(obj) + assert report.type == "offensive_content" + + +def test_report_serializer_to_ap(factories): + report = factories["moderation.Report"](local=True) + expected = { + "@context": jsonld.get_default_context(), + "type": "Flag", + "id": report.fid, + "actor": actors.get_service_actor().fid, + "content": report.summary, + "object": [report.target.fid], + "tag": [{"type": "Hashtag", "name": "#{}".format(report.type)}], + } + serializer = serializers.FlagSerializer(report) + assert serializer.data == expected diff --git a/api/tests/federation/test_spa_views.py b/api/tests/federation/test_spa_views.py new file mode 100644 index 0000000000000000000000000000000000000000..f728419961c1bb325a9e8fc3fbc1ec5dffa064d3 --- /dev/null +++ b/api/tests/federation/test_spa_views.py @@ -0,0 +1,36 @@ +from funkwhale_api.common import utils + + +def test_channel_detail(spa_html, no_api_auth, client, factories, settings): + icon = factories["common.Attachment"]() + actor = factories["federation.Actor"](local=True, attachment_icon=icon) + url = "/@{}".format(actor.preferred_username) + + response = client.get(url) + + assert response.status_code == 200 + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": actor.display_name}, + {"tag": "meta", "property": "og:type", "content": "profile"}, + { + "tag": "meta", + "property": "og:image", + "content": actor.attachment_icon.download_url_medium_square_crop, + }, + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": actor.fid, + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas diff --git a/api/tests/federation/test_third_party_activitypub.py b/api/tests/federation/test_third_party_activitypub.py new file mode 100644 index 0000000000000000000000000000000000000000..34b09c891a69d898f5140f2054d339e6cfb0e7fd --- /dev/null +++ b/api/tests/federation/test_third_party_activitypub.py @@ -0,0 +1,58 @@ +from funkwhale_api.federation import serializers + + +def test_pleroma_actor_from_ap(factories): + + payload = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://test.federation/schemas/litepub-0.1.jsonld", + {"@language": "und"}, + ], + "endpoints": { + "oauthAuthorizationEndpoint": "https://test.federation/oauth/authorize", + "oauthRegistrationEndpoint": "https://test.federation/api/v1/apps", + "oauthTokenEndpoint": "https://test.federation/oauth/token", + "sharedInbox": "https://test.federation/inbox", + "uploadMedia": "https://test.federation/api/ap/upload_media", + }, + "followers": "https://test.federation/internal/fetch/followers", + "following": "https://test.federation/internal/fetch/following", + "id": "https://test.federation/internal/fetch", + "inbox": "https://test.federation/internal/fetch/inbox", + "invisible": True, + "manuallyApprovesFollowers": False, + "name": "Pleroma", + "preferredUsername": "internal.fetch", + "publicKey": { + "id": "https://test.federation/internal/fetch#main-key", + "owner": "https://test.federation/internal/fetch", + "publicKeyPem": "PEM", + }, + "summary": "An internal service actor for this Pleroma instance. No user-serviceable parts inside.", + "type": "Application", + "url": "https://test.federation/internal/fetch", + } + + serializer = serializers.ActorSerializer(data=payload) + assert serializer.is_valid(raise_exception=True) + actor = serializer.save() + + assert actor.fid == payload["id"] + assert actor.url == payload["url"] + assert actor.inbox_url == payload["inbox"] + assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"] + assert actor.outbox_url is None + assert actor.following_url == payload["following"] + assert actor.followers_url == payload["followers"] + assert actor.followers_url == payload["followers"] + assert actor.type == payload["type"] + assert actor.preferred_username == payload["preferredUsername"] + assert actor.name == payload["name"] + assert actor.summary_obj.text == payload["summary"] + assert actor.summary_obj.content_type == "text/html" + assert actor.fid == payload["url"] + assert actor.manually_approves_followers is payload["manuallyApprovesFollowers"] + assert actor.private_key is None + assert actor.public_key == payload["publicKey"]["publicKeyPem"] + assert actor.domain_id == "test.federation" diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index 9502fb6c36c234f641b6cd4a9e2e0e338d0f2336..6ba9ccfaeceb747f3e6a18e14b717652d3c0d283 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -1,6 +1,8 @@ from rest_framework import serializers import pytest +from django.core.exceptions import ObjectDoesNotExist + from funkwhale_api.federation import exceptions, utils @@ -172,3 +174,36 @@ def test_local_qs(factory_name, fids, kwargs, expected_indexes, factories, setti expected_objs = [obj for i, obj in enumerate(objs) if i in expected_indexes] assert list(result) == expected_objs + + +def test_get_obj_by_fid_not_found(): + with pytest.raises(ObjectDoesNotExist): + utils.get_object_by_fid("http://test") + + +def test_get_obj_by_fid_local_not_found(factories): + obj = factories["federation.Actor"](local=False) + with pytest.raises(ObjectDoesNotExist): + utils.get_object_by_fid(obj.fid, local=True) + + +def test_get_obj_by_fid_local(factories): + obj = factories["federation.Actor"](local=True) + assert utils.get_object_by_fid(obj.fid, local=True) == obj + + +@pytest.mark.parametrize( + "factory_name", + [ + "federation.Actor", + "music.Artist", + "music.Album", + "music.Track", + "music.Upload", + "music.Library", + ], +) +def test_get_obj_by_fid(factory_name, factories): + obj = factories[factory_name]() + factories[factory_name]() + assert utils.get_object_by_fid(obj.fid) == obj diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 0b5759937445be16c6547cf055c641368d926c48..bd778778f3ec83c2bf81add7894d4c9c67bce018 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -354,20 +354,24 @@ def test_music_upload_detail_private_approved_follow( @pytest.mark.parametrize( - "accept_header,expected", + "accept_header,default,expected", [ - ("text/html,application/xhtml+xml", True), - ("text/html,application/json", True), - ("", False), - (None, False), - ("application/json", False), - ("application/activity+json", False), - ("application/json,text/html", False), - ("application/activity+json,text/html", False), + ("text/html,application/xhtml+xml", True, True), + ("text/html,application/json", True, True), + ("", True, False), + (None, True, False), + ("application/json", True, False), + ("application/activity+json", True, False), + ("application/json,text/html", True, False), + ("application/activity+json,text/html", True, False), + ("unrelated/ct", True, True), + ("unrelated/ct", False, False), ], ) -def test_should_redirect_ap_to_html(accept_header, expected): - assert federation_utils.should_redirect_ap_to_html(accept_header) is expected +def test_should_redirect_ap_to_html(accept_header, default, expected): + assert ( + federation_utils.should_redirect_ap_to_html(accept_header, default) is expected + ) def test_music_library_retrieve_redirects_to_html_if_header_set( diff --git a/api/tests/moderation/test_views.py b/api/tests/moderation/test_views.py index dba2281720d3a0e3c82214ee001ecdfec5bf9683..9f4196f961b3666a8658a75e205c15faaf81baf0 100644 --- a/api/tests/moderation/test_views.py +++ b/api/tests/moderation/test_views.py @@ -56,3 +56,22 @@ def test_create_report_anonymous(factories, api_client, no_api_auth): assert response.status_code == 201 report = models.Report.objects.latest("id") assert report.submitter_email == data["submitter_email"] + + +def test_create_report_and_forward(factories, api_client, no_api_auth, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + target = factories["music.Artist"](attributed=True) + url = reverse("api:v1:moderation:reports-list") + data = { + "target": {"type": "artist", "id": target.pk}, + "summary": "Test report", + "type": "illegal_content", + "submitter_email": "test@example.test", + "forward": True, + } + response = api_client.post(url, data, format="json") + + assert response.status_code == 201 + report = models.Report.objects.latest("id") + + dispatch.assert_called_once_with({"type": "Flag"}, context={"report": report}) diff --git a/changes/changelog.d/1038.feature b/changes/changelog.d/1038.feature new file mode 100644 index 0000000000000000000000000000000000000000..1e6913ab3caa3b9c4c84e4b24eb3001076f7a619 --- /dev/null +++ b/changes/changelog.d/1038.feature @@ -0,0 +1 @@ +Federated reports (#1038) diff --git a/changes/notes.rst b/changes/notes.rst index 5777f9c07e2055827d2a0a60dd571a5b9c99c93d..b1b9cc655eb2e384aa2ee847e601ec239eea61b3 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -27,6 +27,12 @@ the following instruction is present in your nginx configuration:: add_header Service-Worker-Allowed "/"; } +Federated reports +^^^^^^^^^^^^^^^^^ + +It's now possible to send a copy of a report to the server hosting the reported object, in order to make moderation easier and more distributed. + +This feature is inspired by Mastodon's current design, and should work with at least Funkwhale and Mastodon servers. Improved search performance ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue index 8746fa008adedbf98af223508bec17da4d55490d..058c4b5cbf651022fa8d288c5133ba6268e07af2 100644 --- a/front/src/components/mixins/Report.vue +++ b/front/src/components/mixins/Report.vue @@ -9,6 +9,7 @@ export default { label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}), target: { type: 'account', + _obj: account, full_username: account.full_username, label: account.full_username, typeLabel: this.$pgettext("*/*/*/Noun", 'Account'), @@ -25,6 +26,7 @@ export default { target: { type: 'track', id: track.id, + _obj: track, label: track.title, typeLabel: this.$pgettext("*/*/*/Noun", 'Track'), } @@ -39,6 +41,7 @@ export default { type: 'album', id: album.id, label: album.title, + _obj: album, typeLabel: this.$pgettext("*/*/*", 'Album'), } }) @@ -53,6 +56,7 @@ export default { type: 'artist', id: artist.id, label: artist.name, + _obj: artist, typeLabel: this.$pgettext("*/*/*/Noun", 'Artist'), } }) @@ -64,6 +68,7 @@ export default { type: 'playlist', id: playlist.id, label: playlist.name, + _obj: playlist, typeLabel: this.$pgettext("*/*/*", 'Playlist'), } }) @@ -75,6 +80,7 @@ export default { type: 'library', uuid: library.uuid, label: library.name, + _obj: library, typeLabel: this.$pgettext("*/*/*/Noun", 'Library'), } }) diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue index 0f58712cd2a1d912b57f4874a54e558a4933636e..f53f6e99820a89a043556d0fd712ec62fba63299 100644 --- a/front/src/components/moderation/ReportModal.vue +++ b/front/src/components/moderation/ReportModal.vue @@ -46,6 +46,20 @@ </p> <content-form field-id="report-summary" :rows="8" v-model="summary"></content-form> </div> + <div class="ui field" v-if="!isLocal"> + <div class="ui checkbox"> + <input id="report-forward" v-model="forward" type="checkbox"> + <label for="report-forward"> + <strong> + <translate :translate-params="{domain: targetDomain}" translate-context="*/*/Field.Label/Verb">Forward to %{ domain} </translate> + </strong> + <p> + <translate translate-context="*/*/Field,Help">Forward an anonymized copy of your report to the server hosting this element.</translate> + </p> + </label> + </div> + </div> + <div class="ui hidden divider"></div> </form> <div v-else-if="isLoadingReportTypes" class="ui inline active loader"> @@ -75,6 +89,12 @@ import {mapState} from 'vuex' import logger from '@/logging' +function urlDomain(data) { + var a = document.createElement('a'); + a.href = data; + return a.hostname; +} + export default { components: { ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"), @@ -90,6 +110,7 @@ export default { submitterEmail: '', category: null, reportTypes: [], + forward: false, } }, computed: { @@ -113,6 +134,19 @@ export default { } return this.allowedCategories.length > 0 + }, + targetDomain () { + if (!this.target._obj) { + return + } + let fid = this.target._obj.fid + if (!fid) { + return this.$store.getters['instance/domain'] + } + return urlDomain(fid) + }, + isLocal () { + return this.$store.getters['instance/domain'] === this.targetDomain } }, methods: { @@ -124,9 +158,10 @@ export default { let self = this self.isLoading = true let payload = { - target: this.target, + target: {...this.target, _obj: null}, summary: this.summary, type: this.category, + forward: this.forward, } if (!this.$store.state.auth.authenticated) { payload.submitter_email = this.submitterEmail diff --git a/front/src/store/ui.js b/front/src/store/ui.js index a666af1ee089c62fb4e84789e0709b542c105b6c..2a8515ab95dca0e49ccb38305f4276cf82ede113 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -10,7 +10,7 @@ export default { momentLocale: 'en', lastDate: new Date(), maxMessages: 100, - messageDisplayDuration: 10000, + messageDisplayDuration: 5 * 1000, supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"], messages: [], theme: 'light',