Fix #1038: Federated reports
This commit is contained in:
parent
40720328d7
commit
d9afed5067
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 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)
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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}),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Federated reports (#1038)
|
|
@ -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
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
@ -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'),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue