Fix #1038: Federated reports
This commit is contained in:
parent
40720328d7
commit
d9afed5067
|
@ -1,6 +1,7 @@
|
|||
from django import urls
|
||||
|
||||
from funkwhale_api.audio import spa_views as audio_spa_views
|
||||
from funkwhale_api.federation import spa_views as federation_spa_views
|
||||
from funkwhale_api.music import spa_views
|
||||
|
||||
|
||||
|
@ -36,4 +37,9 @@ urlpatterns = [
|
|||
audio_spa_views.channel_detail_username,
|
||||
name="channel_detail",
|
||||
),
|
||||
urls.re_path(
|
||||
r"^@(?P<username>[^/]+)/?$",
|
||||
federation_spa_views.actor_detail_username,
|
||||
name="actor_detail",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
try:
|
||||
return serve_spa(request)
|
||||
except ApiRedirect as e:
|
||||
return get_api_response(request, e.url)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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}],
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"))
|
||||
|
||||
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):
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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}),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Federated reports (#1038)
|
|
@ -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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
})
|
||||
|
|
|
@ -46,6 +46,20 @@
|
|||
</p>
|
||||
<content-form field-id="report-summary" :rows="8" v-model="summary"></content-form>
|
||||
</div>
|
||||
<div class="ui field" v-if="!isLocal">
|
||||
<div class="ui checkbox">
|
||||
<input id="report-forward" v-model="forward" type="checkbox">
|
||||
<label for="report-forward">
|
||||
<strong>
|
||||
<translate :translate-params="{domain: targetDomain}" translate-context="*/*/Field.Label/Verb">Forward to %{ domain} </translate>
|
||||
</strong>
|
||||
<p>
|
||||
<translate translate-context="*/*/Field,Help">Forward an anonymized copy of your report to the server hosting this element.</translate>
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
</form>
|
||||
<div v-else-if="isLoadingReportTypes" class="ui inline active loader">
|
||||
|
||||
|
@ -75,6 +89,12 @@ import {mapState} from 'vuex'
|
|||
|
||||
import logger from '@/logging'
|
||||
|
||||
function urlDomain(data) {
|
||||
var a = document.createElement('a');
|
||||
a.href = data;
|
||||
return a.hostname;
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ReportCategoryDropdown: () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"),
|
||||
|
@ -90,6 +110,7 @@ export default {
|
|||
submitterEmail: '',
|
||||
category: null,
|
||||
reportTypes: [],
|
||||
forward: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -113,6 +134,19 @@ export default {
|
|||
}
|
||||
|
||||
return this.allowedCategories.length > 0
|
||||
},
|
||||
targetDomain () {
|
||||
if (!this.target._obj) {
|
||||
return
|
||||
}
|
||||
let fid = this.target._obj.fid
|
||||
if (!fid) {
|
||||
return this.$store.getters['instance/domain']
|
||||
}
|
||||
return urlDomain(fid)
|
||||
},
|
||||
isLocal () {
|
||||
return this.$store.getters['instance/domain'] === this.targetDomain
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -124,9 +158,10 @@ export default {
|
|||
let self = this
|
||||
self.isLoading = true
|
||||
let payload = {
|
||||
target: this.target,
|
||||
target: {...this.target, _obj: null},
|
||||
summary: this.summary,
|
||||
type: this.category,
|
||||
forward: this.forward,
|
||||
}
|
||||
if (!this.$store.state.auth.authenticated) {
|
||||
payload.submitter_email = this.submitterEmail
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue