Skip to content
Snippets Groups Projects
Commit d9afed50 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Fix #1038: Federated reports

parent 40720328
Branches
No related tags found
No related merge requests found
Showing
with 537 additions and 63 deletions
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 = [ ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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(): ...@@ -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: ...@@ -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):
try:
return serve_spa(request) 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): ...@@ -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 ...@@ -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 = [ ...@@ -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"]) ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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(): ...@@ -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): ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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()
......
import cryptography.exceptions
import datetime import datetime
import logging import logging
import pytz import pytz
...@@ -31,18 +32,29 @@ def verify_date(raw_date): ...@@ -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(
"Verifying request with date %s and headers %s", date, str(request.headers)
)
verify_date(date)
try:
return requests_http_signature.HTTPSignatureAuth.verify( return requests_http_signature.HTTPSignatureAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False 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):
......
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
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): ...@@ -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): ...@@ -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): ...@@ -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 = { ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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 = ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment