Fix #1038: Federated reports

This commit is contained in:
Eliot Berriot 2020-03-11 11:39:55 +01:00
parent 40720328d7
commit d9afed5067
34 changed files with 985 additions and 76 deletions

View File

@ -1,6 +1,7 @@
from django import urls from django import urls
from funkwhale_api.audio import spa_views as audio_spa_views 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 from funkwhale_api.music import spa_views
@ -36,4 +37,9 @@ urlpatterns = [
audio_spa_views.channel_detail_username, audio_spa_views.channel_detail_username,
name="channel_detail", name="channel_detail",
), ),
urls.re_path(
r"^@(?P<username>[^/]+)/?$",
federation_spa_views.actor_detail_username,
name="actor_detail",
),
] ]

View File

@ -64,6 +64,10 @@ class Channel(models.Model):
) )
) )
@property
def fid(self):
return self.actor.fid
def generate_actor(username, **kwargs): def generate_actor(username, **kwargs):
actor_data = user_models.get_actor_data(username, **kwargs) actor_data = user_models.get_actor_data(username, **kwargs)

View File

@ -7,6 +7,7 @@ from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import middleware
from funkwhale_api.common import utils from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import spa_views from funkwhale_api.music import spa_views
@ -14,7 +15,7 @@ from funkwhale_api.music import spa_views
from . import models from . import models
def channel_detail(query): def channel_detail(query, redirect_to_ap):
queryset = models.Channel.objects.filter(query).select_related( queryset = models.Channel.objects.filter(query).select_related(
"artist__attachment_cover", "actor", "library" "artist__attachment_cover", "actor", "library"
) )
@ -23,6 +24,9 @@ def channel_detail(query):
except models.Channel.DoesNotExist: except models.Channel.DoesNotExist:
return [] return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.actor.fid)
obj_url = utils.join_url( obj_url = utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse( utils.spa_reverse(
@ -81,16 +85,16 @@ def channel_detail(query):
return metas return metas
def channel_detail_uuid(request, uuid): def channel_detail_uuid(request, uuid, redirect_to_ap):
validator = serializers.UUIDField().to_internal_value validator = serializers.UUIDField().to_internal_value
try: try:
uuid = validator(uuid) uuid = validator(uuid)
except serializers.ValidationError: except serializers.ValidationError:
return [] 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 validator = federation_utils.get_actor_data_from_username
try: try:
username_data = validator(username) username_data = validator(username)
@ -100,4 +104,4 @@ def channel_detail_username(request, username):
actor__domain=username_data["domain"], actor__domain=username_data["domain"],
actor__preferred_username__iexact=username_data["username"], actor__preferred_username__iexact=username_data["username"],
) )
return channel_detail(query) return channel_detail(query, redirect_to_ap)

View File

@ -4,6 +4,7 @@ import io
import os import os
import re import re
import time import time
import urllib.parse
import xml.sax.saxutils import xml.sax.saxutils
from django import http from django import http
@ -163,8 +164,16 @@ def render_tags(tags):
def get_request_head_tags(request): 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) 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(): def get_custom_css():
@ -175,6 +184,30 @@ def get_custom_css():
return xml.sax.saxutils.escape(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: class SPAFallbackMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
@ -183,7 +216,10 @@ class SPAFallbackMiddleware:
response = self.get_response(request) response = self.get_response(request)
if response.status_code == 404 and should_fallback_to_spa(request.path): 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 return response

View File

@ -165,14 +165,14 @@ def receive(activity, on_behalf_of, inbox_actor=None):
return return
local_to_recipients = get_actors_from_audience(activity.get("to", [])) 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 = local_to_recipients.values_list("pk", flat=True)
local_to_recipients = list(local_to_recipients) local_to_recipients = list(local_to_recipients)
if inbox_actor: if inbox_actor:
local_to_recipients.append(inbox_actor.pk) local_to_recipients.append(inbox_actor.pk)
local_cc_recipients = get_actors_from_audience(activity.get("cc", [])) 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) local_cc_recipients = local_cc_recipients.values_list("pk", flat=True)
inbox_items = [] inbox_items = []
@ -457,6 +457,13 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
else: else:
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url) remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_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": elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors # we want to broadcast the activity to other instances service actors

View File

@ -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} 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"]) LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"]) SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"]) FW = NS(CONTEXTS_BY_ID["FW"])
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])

View File

@ -125,7 +125,8 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
self.domain = models.Domain.objects.get_or_create( self.domain = models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME name=settings.FEDERATION_HOSTNAME
)[0] )[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 not create:
if extracted and hasattr(extracted, "pk"): if extracted and hasattr(extracted, "pk"):
extracted.actor = self extracted.actor = self
@ -166,7 +167,9 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "music.Library" model = "music.Library"
class Params: class Params:
local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True)) local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True)
)
@registry.register @registry.register

View File

@ -17,6 +17,10 @@ def cached_contexts(loader):
for cached in contexts.CONTEXTS: for cached in contexts.CONTEXTS:
if url == cached["documentUrl"]: if url == cached["documentUrl"]:
return cached 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 loader(url, *args, **kwargs)
return load return load
@ -29,18 +33,19 @@ def get_document_loader():
return cached_contexts(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 = options or {}
options.setdefault("documentLoader", get_document_loader()) options.setdefault("documentLoader", get_document_loader())
if isinstance(doc, str): if isinstance(doc, str):
doc = options["documentLoader"](doc)["document"] doc = options["documentLoader"](doc)["document"]
if insert_fw_context: for context_name in default_contexts:
fw = contexts.CONTEXTS_BY_ID["FW"]["documentUrl"] ctx = contexts.CONTEXTS_BY_ID[context_name]["documentUrl"]
try: try:
insert_context(fw, doc) insert_context(ctx, doc)
except KeyError: except KeyError:
# probably an already expanded document # probably an already expanded document
pass pass
result = pyld.jsonld.expand(doc, options=options) result = pyld.jsonld.expand(doc, options=options)
try: try:
# jsonld.expand returns a list, which is useless for us # jsonld.expand returns a list, which is useless for us

View File

@ -443,26 +443,29 @@ class Activity(models.Model):
type = models.CharField(db_index=True, null=True, max_length=100) type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations # generic relations
object_id = models.IntegerField(null=True) object_id = models.IntegerField(null=True, blank=True)
object_content_type = models.ForeignKey( object_content_type = models.ForeignKey(
ContentType, ContentType,
null=True, null=True,
blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="objecting_activities", related_name="objecting_activities",
) )
object = GenericForeignKey("object_content_type", "object_id") 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( target_content_type = models.ForeignKey(
ContentType, ContentType,
null=True, null=True,
blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="targeting_activities", related_name="targeting_activities",
) )
target = GenericForeignKey("target_content_type", "target_id") 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( related_object_content_type = models.ForeignKey(
ContentType, ContentType,
null=True, null=True,
blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="related_objecting_activities", related_name="related_objecting_activities",
) )

View File

@ -451,3 +451,35 @@ def inbox_delete_actor(payload, context):
logger.warn("Cannot delete actor %s, no matching object found", actor.fid) logger.warn("Cannot delete actor %s, no matching object found", actor.fid)
return return
actor.delete() 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}],
),
}

View File

@ -2,6 +2,7 @@ import logging
import urllib.parse import urllib.parse
import uuid import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
@ -9,6 +10,9 @@ from rest_framework import serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import models as common_models 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 licenses
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
@ -36,13 +40,20 @@ class MediaSerializer(jsonld.JsonLdSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
super().__init__(*args, **kwargs) 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): def validate_mediaType(self, v):
if not self.allowed_mimetypes: if not self.allowed_mimetypes:
# no restrictions # no restrictions
return v return v
if self.allow_empty_mimetype and not v:
return None
for mt in self.allowed_mimetypes: for mt in self.allowed_mimetypes:
if mt.endswith("/*"): if mt.endswith("/*"):
if v.startswith(mt.replace("*", "")): if v.startswith(mt.replace("*", "")):
return v return v
@ -147,7 +158,10 @@ class ActorSerializer(jsonld.JsonLdSerializer):
publicKey = PublicKeySerializer(required=False) publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False) endpoints = EndpointsSerializer(required=False)
icon = ImageSerializer( icon = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
) )
class Meta: class Meta:
@ -294,7 +308,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file( common_utils.attach_file(
actor, actor,
"attachment_icon", "attachment_icon",
{"url": new_value["url"], "mimetype": new_value["mediaType"]} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None,
) )
@ -1030,7 +1044,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
return validated_data return validated_data
# create the attachment by hand so it can be attached as the cover # create the attachment by hand so it can be attached as the cover
validated_data["attachment_cover"] = common_models.Attachment.objects.create( validated_data["attachment_cover"] = common_models.Attachment.objects.create(
mimetype=attachment_cover["mediaType"], mimetype=attachment_cover.get("mediaType"),
url=attachment_cover["url"], url=attachment_cover["url"],
actor=instance.attributed_to, actor=instance.attributed_to,
) )
@ -1048,7 +1062,10 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
class ArtistSerializer(MusicEntitySerializer): class ArtistSerializer(MusicEntitySerializer):
image = ImageSerializer( image = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
) )
updateable_fields = [ updateable_fields = [
("name", "name"), ("name", "name"),
@ -1094,7 +1111,10 @@ class AlbumSerializer(MusicEntitySerializer):
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
# XXX: 1.0 rename to image # XXX: 1.0 rename to image
cover = ImageSerializer( cover = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
) )
updateable_fields = [ updateable_fields = [
("name", "title"), ("name", "title"),
@ -1172,7 +1192,10 @@ class TrackSerializer(MusicEntitySerializer):
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
image = ImageSerializer( image = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
) )
updateable_fields = [ updateable_fields = [
@ -1437,6 +1460,85 @@ class ActorDeleteSerializer(jsonld.JsonLdSerializer):
jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)} 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): class NodeInfoLinkSerializer(serializers.Serializer):
href = serializers.URLField() href = serializers.URLField()
rel = serializers.URLField() rel = serializers.URLField()

View File

@ -1,3 +1,4 @@
import cryptography.exceptions
import datetime import datetime
import logging import logging
import pytz import pytz
@ -31,18 +32,29 @@ def verify_date(raw_date):
now = timezone.now() now = timezone.now()
if dt < now - delta or dt > now + delta: if dt < now - delta or dt > now + delta:
raise forms.ValidationError( 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 return dt
def verify(request, public_key): def verify(request, public_key):
verify_date(request.headers.get("Date")) date = request.headers.get("Date")
logger.debug(
return requests_http_signature.HTTPSignatureAuth.verify( "Verifying request with date %s and headers %s", date, str(request.headers)
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
) )
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): def verify_django(django_request, public_key):

View File

@ -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

View File

@ -1,8 +1,12 @@
import html.parser import html.parser
import unicodedata import unicodedata
import urllib.parse
import re import re
from django.apps import apps
from django.conf import settings 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.common import session
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
@ -203,7 +207,7 @@ def find_alternate(response_text):
return parser.result 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: if not accept_header:
return False return False
@ -223,4 +227,43 @@ def should_redirect_ap_to_html(accept_header):
if ct in no_redirect_headers: if ct in no_redirect_headers:
return False 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)

View File

@ -63,6 +63,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class Params: class Params:
anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email"))
local = factory.Trait(fid=None)
assigned = factory.Trait( assigned = factory.Trait(
assigned_to=factory.SubFactory(federation_factories.ActorFactory) assigned_to=factory.SubFactory(federation_factories.ActorFactory)
) )

View File

@ -194,6 +194,27 @@ TARGET_CONFIG = {
TARGET_FIELD = common_fields.GenericRelation(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): class ReportSerializer(serializers.ModelSerializer):
target = TARGET_FIELD target = TARGET_FIELD
@ -234,29 +255,7 @@ class ReportSerializer(serializers.ModelSerializer):
return validated_data return validated_data
def create(self, validated_data): def create(self, validated_data):
target_state_serializer = state_serializers[ validated_data["target_state"] = get_target_state(validated_data["target"])
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_owner"] = get_target_owner(validated_data["target"]) validated_data["target_owner"] = get_target_owner(validated_data["target"])
r = super().create(validated_data) r = super().create(validated_data)
tasks.signals.report_created.send(sender=None, report=r) tasks.signals.report_created.send(sender=None, report=r)

View File

@ -5,6 +5,9 @@ from rest_framework import response
from rest_framework import status from rest_framework import status
from rest_framework import viewsets 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 models
from . import serializers from . import serializers
@ -66,4 +69,13 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
submitter = None submitter = None
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
submitter = self.request.user.actor 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})

View File

@ -5,6 +5,7 @@ from django.urls import reverse
from django.db.models import Q from django.db.models import Q
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import middleware
from funkwhale_api.common import utils from funkwhale_api.common import utils
from funkwhale_api.playlists import models as playlists_models 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") queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist")
try: try:
obj = queryset.get() obj = queryset.get()
except models.Track.DoesNotExist: except models.Track.DoesNotExist:
return [] return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
track_url = utils.join_url( track_url = utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_track", kwargs={"pk": obj.pk}), utils.spa_reverse("library_track", kwargs={"pk": obj.pk}),
@ -114,12 +119,16 @@ def library_track(request, pk):
return metas 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") queryset = models.Album.objects.filter(pk=pk).select_related("artist")
try: try:
obj = queryset.get() obj = queryset.get()
except models.Album.DoesNotExist: except models.Album.DoesNotExist:
return [] return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
album_url = utils.join_url( album_url = utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": obj.pk}), utils.spa_reverse("library_album", kwargs={"pk": obj.pk}),
@ -182,12 +191,16 @@ def library_album(request, pk):
return metas return metas
def library_artist(request, pk): def library_artist(request, pk, redirect_to_ap):
queryset = models.Artist.objects.filter(pk=pk) queryset = models.Artist.objects.filter(pk=pk)
try: try:
obj = queryset.get() obj = queryset.get()
except models.Artist.DoesNotExist: except models.Artist.DoesNotExist:
return [] return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
artist_url = utils.join_url( artist_url = utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}), utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}),
@ -242,7 +255,7 @@ def library_artist(request, pk):
return metas 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") queryset = playlists_models.Playlist.objects.filter(pk=pk, privacy_level="everyone")
try: try:
obj = queryset.get() obj = queryset.get()
@ -294,12 +307,16 @@ def library_playlist(request, pk):
return metas return metas
def library_library(request, uuid): def library_library(request, uuid, redirect_to_ap):
queryset = models.Library.objects.filter(uuid=uuid) queryset = models.Library.objects.filter(uuid=uuid)
try: try:
obj = queryset.get() obj = queryset.get()
except models.Library.DoesNotExist: except models.Library.DoesNotExist:
return [] return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
library_url = utils.join_url( library_url = utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}), utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}),

View File

@ -18,7 +18,11 @@ env =
EMAIL_CONFIG=consolemail:// EMAIL_CONFIG=consolemail://
CELERY_BROKER_URL=memory:// CELERY_BROKER_URL=memory://
CELERY_TASK_ALWAYS_EAGER=True CELERY_TASK_ALWAYS_EAGER=True
FUNKWHALE_HOSTNAME_SUFFIX=
FUNKWHALE_HOSTNAME_PREFIX=
FUNKWHALE_HOSTNAME=test.federation
FEDERATION_HOSTNAME=test.federation FEDERATION_HOSTNAME=test.federation
FUNKWHALE_URL=https://test.federation
DEBUG_TOOLBAR_ENABLED=False DEBUG_TOOLBAR_ENABLED=False
DEBUG=False DEBUG=False
WEAK_PASSWORDS=True WEAK_PASSWORDS=True

View File

@ -8,6 +8,7 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.common import middleware from funkwhale_api.common import middleware
from funkwhale_api.common import throttling from funkwhale_api.common import throttling
from funkwhale_api.common import utils
def test_spa_fallback_middleware_no_404(mocker): 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): def test_get_route_head_tags(mocker, settings):
match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock()) match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock())
resolve = mocker.patch("django.urls.resolve", return_value=match) 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) tags = middleware.get_request_head_tags(request)
assert tags == match.func.return_value 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) 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 expected_url
) )
assert response.content == expected_html.encode() 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

View File

@ -456,6 +456,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
shared_inbox_url=remote_actor1.shared_inbox_url shared_inbox_url=remote_actor1.shared_inbox_url
) )
remote_actor3 = factories["federation.Actor"](shared_inbox_url=None) remote_actor3 = factories["federation.Actor"](shared_inbox_url=None)
remote_actor4 = factories["federation.Actor"]()
library = factories["music.Library"]() library = factories["music.Library"]()
library_follower_local = factories["federation.LibraryFollow"]( library_follower_local = factories["federation.LibraryFollow"](
@ -491,6 +492,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
activity.PUBLIC_ADDRESS, activity.PUBLIC_ADDRESS,
{"type": "followers", "target": library}, {"type": "followers", "target": library},
{"type": "followers", "target": followed_actor}, {"type": "followers", "target": followed_actor},
{"type": "actor_inbox", "actor": remote_actor4},
] ]
inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items( 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_actor1.shared_inbox_url),
models.Delivery(inbox_url=remote_actor3.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=library_follower_remote.inbox_url),
models.Delivery(inbox_url=actor_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, activity.PUBLIC_ADDRESS,
library.followers_url, library.followers_url,
followed_actor.followers_url, followed_actor.followers_url,
remote_actor4.fid,
] ]
assert urls == expected_urls assert urls == expected_urls

View File

@ -67,6 +67,95 @@ def test_expand_no_external_request():
assert doc == expected 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): def test_expand_remote_doc(r_mock):
url = "https://noop/federation/actors/demo" url = "https://noop/federation/actors/demo"
payload = { payload = {

View File

@ -8,6 +8,7 @@ from funkwhale_api.federation import (
routes, routes,
serializers, serializers,
) )
from funkwhale_api.moderation import serializers as moderation_serializers
@pytest.mark.parametrize( @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": "Album"}}, routes.inbox_update_album),
({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track), ({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track),
({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor), ({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
({"type": "Flag"}, routes.inbox_flag),
], ],
) )
def test_inbox_routes(route, handler): def test_inbox_routes(route, handler):
@ -44,6 +46,7 @@ def test_inbox_routes(route, handler):
"route,handler", "route,handler",
[ [
({"type": "Accept"}, routes.outbox_accept), ({"type": "Accept"}, routes.outbox_accept),
({"type": "Flag"}, routes.outbox_flag),
({"type": "Follow"}, routes.outbox_follow), ({"type": "Follow"}, routes.outbox_follow),
({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio), ({"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! # actor should still be here!
local_actor.refresh_from_db() 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()

View File

@ -6,12 +6,14 @@ from django.core.paginator import Paginator
from django.utils import timezone from django.utils import timezone
from funkwhale_api.common import utils as common_utils 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 contexts
from funkwhale_api.federation import keys from funkwhale_api.federation import keys
from funkwhale_api.federation import jsonld from funkwhale_api.federation import jsonld
from funkwhale_api.federation import models from funkwhale_api.federation import models
from funkwhale_api.federation import serializers from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils from funkwhale_api.federation import utils
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.music import licenses 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"] 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): def test_actor_serializer_only_mandatory_field_from_ap(db):
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
@ -1477,3 +1509,44 @@ def test_channel_create_upload_serializer(factories):
serializer = serializers.ChannelCreateUploadSerializer(upload) serializer = serializers.ChannelCreateUploadSerializer(upload)
assert serializer.data == expected 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

View File

@ -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

View File

@ -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"

View File

@ -1,6 +1,8 @@
from rest_framework import serializers from rest_framework import serializers
import pytest import pytest
from django.core.exceptions import ObjectDoesNotExist
from funkwhale_api.federation import exceptions, utils 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] expected_objs = [obj for i, obj in enumerate(objs) if i in expected_indexes]
assert list(result) == expected_objs 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

View File

@ -354,20 +354,24 @@ def test_music_upload_detail_private_approved_follow(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"accept_header,expected", "accept_header,default,expected",
[ [
("text/html,application/xhtml+xml", True), ("text/html,application/xhtml+xml", True, True),
("text/html,application/json", True), ("text/html,application/json", True, True),
("", False), ("", True, False),
(None, False), (None, True, False),
("application/json", False), ("application/json", True, False),
("application/activity+json", False), ("application/activity+json", True, False),
("application/json,text/html", False), ("application/json,text/html", True, False),
("application/activity+json,text/html", 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): def test_should_redirect_ap_to_html(accept_header, default, expected):
assert federation_utils.should_redirect_ap_to_html(accept_header) is 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( def test_music_library_retrieve_redirects_to_html_if_header_set(

View File

@ -56,3 +56,22 @@ def test_create_report_anonymous(factories, api_client, no_api_auth):
assert response.status_code == 201 assert response.status_code == 201
report = models.Report.objects.latest("id") report = models.Report.objects.latest("id")
assert report.submitter_email == data["submitter_email"] 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})

View File

@ -0,0 +1 @@
Federated reports (#1038)

View File

@ -27,6 +27,12 @@ the following instruction is present in your nginx configuration::
add_header Service-Worker-Allowed "/"; 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 Improved search performance
^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -9,6 +9,7 @@ export default {
label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}), label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}),
target: { target: {
type: 'account', type: 'account',
_obj: account,
full_username: account.full_username, full_username: account.full_username,
label: account.full_username, label: account.full_username,
typeLabel: this.$pgettext("*/*/*/Noun", 'Account'), typeLabel: this.$pgettext("*/*/*/Noun", 'Account'),
@ -25,6 +26,7 @@ export default {
target: { target: {
type: 'track', type: 'track',
id: track.id, id: track.id,
_obj: track,
label: track.title, label: track.title,
typeLabel: this.$pgettext("*/*/*/Noun", 'Track'), typeLabel: this.$pgettext("*/*/*/Noun", 'Track'),
} }
@ -39,6 +41,7 @@ export default {
type: 'album', type: 'album',
id: album.id, id: album.id,
label: album.title, label: album.title,
_obj: album,
typeLabel: this.$pgettext("*/*/*", 'Album'), typeLabel: this.$pgettext("*/*/*", 'Album'),
} }
}) })
@ -53,6 +56,7 @@ export default {
type: 'artist', type: 'artist',
id: artist.id, id: artist.id,
label: artist.name, label: artist.name,
_obj: artist,
typeLabel: this.$pgettext("*/*/*/Noun", 'Artist'), typeLabel: this.$pgettext("*/*/*/Noun", 'Artist'),
} }
}) })
@ -64,6 +68,7 @@ export default {
type: 'playlist', type: 'playlist',
id: playlist.id, id: playlist.id,
label: playlist.name, label: playlist.name,
_obj: playlist,
typeLabel: this.$pgettext("*/*/*", 'Playlist'), typeLabel: this.$pgettext("*/*/*", 'Playlist'),
} }
}) })
@ -75,6 +80,7 @@ export default {
type: 'library', type: 'library',
uuid: library.uuid, uuid: library.uuid,
label: library.name, label: library.name,
_obj: library,
typeLabel: this.$pgettext("*/*/*/Noun", 'Library'), typeLabel: this.$pgettext("*/*/*/Noun", 'Library'),
} }
}) })

View File

@ -46,6 +46,20 @@
</p> </p>
<content-form field-id="report-summary" :rows="8" v-model="summary"></content-form> <content-form field-id="report-summary" :rows="8" v-model="summary"></content-form>
</div> </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> </form>
<div v-else-if="isLoadingReportTypes" class="ui inline active loader"> <div v-else-if="isLoadingReportTypes" class="ui inline active loader">
@ -75,6 +89,12 @@ import {mapState} from 'vuex'
import logger from '@/logging' import logger from '@/logging'
function urlDomain(data) {
var a = document.createElement('a');
a.href = data;
return a.hostname;
}
export default { export default {
components: { components: {
ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"), ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"),
@ -90,6 +110,7 @@ export default {
submitterEmail: '', submitterEmail: '',
category: null, category: null,
reportTypes: [], reportTypes: [],
forward: false,
} }
}, },
computed: { computed: {
@ -113,6 +134,19 @@ export default {
} }
return this.allowedCategories.length > 0 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: { methods: {
@ -124,9 +158,10 @@ export default {
let self = this let self = this
self.isLoading = true self.isLoading = true
let payload = { let payload = {
target: this.target, target: {...this.target, _obj: null},
summary: this.summary, summary: this.summary,
type: this.category, type: this.category,
forward: this.forward,
} }
if (!this.$store.state.auth.authenticated) { if (!this.$store.state.auth.authenticated) {
payload.submitter_email = this.submitterEmail payload.submitter_email = this.submitterEmail

View File

@ -10,7 +10,7 @@ export default {
momentLocale: 'en', momentLocale: 'en',
lastDate: new Date(), lastDate: new Date(),
maxMessages: 100, maxMessages: 100,
messageDisplayDuration: 10000, messageDisplayDuration: 5 * 1000,
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"], supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
messages: [], messages: [],
theme: 'light', theme: 'light',