Fix #1038: Federated reports

environments/review-front-list-6rg6z1/deployments/4496
Eliot Berriot 2020-03-11 11:39:55 +01:00
rodzic 40720328d7
commit d9afed5067
34 zmienionych plików z 985 dodań i 76 usunięć

Wyświetl plik

@ -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",
),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -4,6 +4,7 @@ import io
import os
import re
import time
import urllib.parse
import xml.sax.saxutils
from django import http
@ -163,8 +164,16 @@ def render_tags(tags):
def get_request_head_tags(request):
accept_header = request.headers.get("Accept") or None
redirect_to_ap = (
False
if not accept_header
else not federation_utils.should_redirect_ap_to_html(accept_header)
)
match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF)
return match.func(request, *match.args, **match.kwargs)
return match.func(
request, *match.args, redirect_to_ap=redirect_to_ap, **match.kwargs
)
def get_custom_css():
@ -175,6 +184,30 @@ def get_custom_css():
return xml.sax.saxutils.escape(css)
class ApiRedirect(Exception):
def __init__(self, url):
self.url = url
def get_api_response(request, url):
"""
Quite ugly but we have no choice. When Accept header is set to application/activity+json
some clients expect to get a JSON payload (instead of the HTML we return). Since
redirecting to the URL does not work (because it makes the signature verification fail),
we grab the internal view corresponding to the URL, call it and return this as the
response
"""
path = urllib.parse.urlparse(url).path
try:
match = urls.resolve(path)
except urls.exceptions.Resolver404:
return http.HttpResponseNotFound()
response = match.func(request, *match.args, **match.kwargs)
response.render()
return response
class SPAFallbackMiddleware:
def __init__(self, get_response):
self.get_response = get_response
@ -183,7 +216,10 @@ class SPAFallbackMiddleware:
response = self.get_response(request)
if response.status_code == 404 and should_fallback_to_spa(request.path):
return serve_spa(request)
try:
return serve_spa(request)
except ApiRedirect as e:
return get_api_response(request, e.url)
return response

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}],
),
}

Wyświetl plik

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

Wyświetl plik

@ -1,3 +1,4 @@
import cryptography.exceptions
import datetime
import logging
import pytz
@ -31,18 +32,29 @@ def verify_date(raw_date):
now = timezone.now()
if dt < now - delta or dt > now + delta:
raise forms.ValidationError(
"Request Date is too far in the future or in the past"
"Request Date {} is too far in the future or in the past".format(raw_date)
)
return dt
def verify(request, public_key):
verify_date(request.headers.get("Date"))
return requests_http_signature.HTTPSignatureAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
date = request.headers.get("Date")
logger.debug(
"Verifying request with date %s and headers %s", date, str(request.headers)
)
verify_date(date)
try:
return requests_http_signature.HTTPSignatureAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
)
except cryptography.exceptions.InvalidSignature:
logger.warning(
"Could not verify request with date %s and headers %s",
date,
str(request.headers),
)
raise
def verify_django(django_request, public_key):

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}),

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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'),
}
})

Wyświetl plik

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

Wyświetl plik

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