From d9afed5067280a3eb6973560f90266d4ee2b4f26 Mon Sep 17 00:00:00 2001
From: Eliot Berriot
Date: Wed, 11 Mar 2020 11:39:55 +0100
Subject: [PATCH] Fix #1038: Federated reports
---
api/config/spa_urls.py | 6 +
api/funkwhale_api/audio/models.py | 4 +
api/funkwhale_api/audio/spa_views.py | 14 ++-
api/funkwhale_api/common/middleware.py | 40 +++++-
api/funkwhale_api/federation/activity.py | 11 +-
api/funkwhale_api/federation/contexts.py | 33 +++++
api/funkwhale_api/federation/factories.py | 7 +-
api/funkwhale_api/federation/jsonld.py | 13 +-
api/funkwhale_api/federation/models.py | 9 +-
api/funkwhale_api/federation/routes.py | 32 +++++
api/funkwhale_api/federation/serializers.py | 114 +++++++++++++++++-
api/funkwhale_api/federation/signing.py | 22 +++-
api/funkwhale_api/federation/spa_views.py | 63 ++++++++++
api/funkwhale_api/federation/utils.py | 49 +++++++-
api/funkwhale_api/moderation/factories.py | 1 +
api/funkwhale_api/moderation/serializers.py | 45 ++++---
api/funkwhale_api/moderation/views.py | 14 ++-
api/funkwhale_api/music/spa_views.py | 27 ++++-
api/setup.cfg | 4 +
api/tests/common/test_middleware.py | 92 +++++++++++++-
api/tests/federation/test_activity.py | 4 +
api/tests/federation/test_jsonld.py | 89 ++++++++++++++
api/tests/federation/test_routes.py | 69 +++++++++++
api/tests/federation/test_serializers.py | 73 +++++++++++
api/tests/federation/test_spa_views.py | 36 ++++++
.../test_third_party_activitypub.py | 58 +++++++++
api/tests/federation/test_utils.py | 35 ++++++
api/tests/federation/test_views.py | 26 ++--
api/tests/moderation/test_views.py | 19 +++
changes/changelog.d/1038.feature | 1 +
changes/notes.rst | 6 +
front/src/components/mixins/Report.vue | 6 +
.../src/components/moderation/ReportModal.vue | 37 +++++-
front/src/store/ui.js | 2 +-
34 files changed, 985 insertions(+), 76 deletions(-)
create mode 100644 api/funkwhale_api/federation/spa_views.py
create mode 100644 api/tests/federation/test_spa_views.py
create mode 100644 api/tests/federation/test_third_party_activitypub.py
create mode 100644 changes/changelog.d/1038.feature
diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py
index 8c9fe5149..7b4b5e169 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[^/]+)/?$",
+ 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 308113836..bdf700f78 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 c76669d39..32dc7f585 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 6122adbca..201cd2ec8 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 b8d04164c..7d9d25a2d 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 b3fc112f0..3e61c03fb 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 e91d8dac9..97158582d 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 450490c91..05a438606 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 9514f203a..357936955 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 32a8357db..70f312d1a 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 2adbcbec4..08b51a630 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 0d922d325..b69c48668 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 000000000..af7e210cf
--- /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 8070b1310..b4f63e680 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 b426a6cea..96e6b5d11 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 81e5846bb..7d772d39e 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 67de68001..b3a91594a 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 073f5bb96..7997a3c07 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 f50bd5473..581396c37 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 d3908e3f9..88e8d0584 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 bac27efc5..89a25b22a 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 7bf906d50..92253f921 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 587ccc333..f63f82896 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 f2fd68eb3..e203e0aff 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 000000000..f72841996
--- /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 000000000..34b09c891
--- /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 9502fb6c3..6ba9ccfae 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 0b5759937..bd778778f 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 dba228172..9f4196f96 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 000000000..1e6913ab3
--- /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 5777f9c07..b1b9cc655 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 8746fa008..058c4b5cb 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 0f58712cd..f53f6e998 100644
--- a/front/src/components/moderation/ReportModal.vue
+++ b/front/src/components/moderation/ReportModal.vue
@@ -46,6 +46,20 @@
+
+
+
+
+
+
+
@@ -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 a666af1ee..2a8515ab9 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',