Commit d9afed50 authored by Agate's avatar Agate 💬

Fix #1038: Federated reports

parent 40720328
from django import urls
from funkwhale_api.audio import spa_views as audio_spa_views
from funkwhale_api.federation import spa_views as federation_spa_views
from funkwhale_api.music import spa_views
......@@ -36,4 +37,9 @@ urlpatterns = [
audio_spa_views.channel_detail_username,
name="channel_detail",
),
urls.re_path(
r"^@(?P<username>[^/]+)/?$",
federation_spa_views.actor_detail_username,
name="actor_detail",
),
]
......@@ -64,6 +64,10 @@ class Channel(models.Model):
)
)
@property
def fid(self):
return self.actor.fid
def generate_actor(username, **kwargs):
actor_data = user_models.get_actor_data(username, **kwargs)
......
......@@ -7,6 +7,7 @@ from django.urls import reverse
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.common import middleware
from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import spa_views
......@@ -14,7 +15,7 @@ from funkwhale_api.music import spa_views
from . import models
def channel_detail(query):
def channel_detail(query, redirect_to_ap):
queryset = models.Channel.objects.filter(query).select_related(
"artist__attachment_cover", "actor", "library"
)
......@@ -23,6 +24,9 @@ def channel_detail(query):
except models.Channel.DoesNotExist:
return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.actor.fid)
obj_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse(
......@@ -81,16 +85,16 @@ def channel_detail(query):
return metas
def channel_detail_uuid(request, uuid):
def channel_detail_uuid(request, uuid, redirect_to_ap):
validator = serializers.UUIDField().to_internal_value
try:
uuid = validator(uuid)
except serializers.ValidationError:
return []
return channel_detail(Q(uuid=uuid))
return channel_detail(Q(uuid=uuid), redirect_to_ap)
def channel_detail_username(request, username):
def channel_detail_username(request, username, redirect_to_ap):
validator = federation_utils.get_actor_data_from_username
try:
username_data = validator(username)
......@@ -100,4 +104,4 @@ def channel_detail_username(request, username):
actor__domain=username_data["domain"],
actor__preferred_username__iexact=username_data["username"],
)
return channel_detail(query)
return channel_detail(query, redirect_to_ap)
......@@ -4,6 +4,7 @@ import io
import os
import re
import time
import urllib.parse
import xml.sax.saxutils
from django import http
......@@ -163,8 +164,16 @@ def render_tags(tags):
def get_request_head_tags(request):
accept_header = request.headers.get("Accept") or None
redirect_to_ap = (
False
if not accept_header
else not federation_utils.should_redirect_ap_to_html(accept_header)
)
match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF)
return match.func(request, *match.args, **match.kwargs)
return match.func(
request, *match.args, redirect_to_ap=redirect_to_ap, **match.kwargs
)
def get_custom_css():
......@@ -175,6 +184,30 @@ def get_custom_css():
return xml.sax.saxutils.escape(css)
class ApiRedirect(Exception):
def __init__(self, url):
self.url = url
def get_api_response(request, url):
"""
Quite ugly but we have no choice. When Accept header is set to application/activity+json
some clients expect to get a JSON payload (instead of the HTML we return). Since
redirecting to the URL does not work (because it makes the signature verification fail),
we grab the internal view corresponding to the URL, call it and return this as the
response
"""
path = urllib.parse.urlparse(url).path
try:
match = urls.resolve(path)
except urls.exceptions.Resolver404:
return http.HttpResponseNotFound()
response = match.func(request, *match.args, **match.kwargs)
response.render()
return response
class SPAFallbackMiddleware:
def __init__(self, get_response):
self.get_response = get_response
......@@ -183,7 +216,10 @@ class SPAFallbackMiddleware:
response = self.get_response(request)
if response.status_code == 404 and should_fallback_to_spa(request.path):
return serve_spa(request)
try:
return serve_spa(request)
except ApiRedirect as e:
return get_api_response(request, e.url)
return response
......
......@@ -165,14 +165,14 @@ def receive(activity, on_behalf_of, inbox_actor=None):
return
local_to_recipients = get_actors_from_audience(activity.get("to", []))
local_to_recipients = local_to_recipients.exclude(user=None)
local_to_recipients = local_to_recipients.local()
local_to_recipients = local_to_recipients.values_list("pk", flat=True)
local_to_recipients = list(local_to_recipients)
if inbox_actor:
local_to_recipients.append(inbox_actor.pk)
local_cc_recipients = get_actors_from_audience(activity.get("cc", []))
local_cc_recipients = local_cc_recipients.exclude(user=None)
local_cc_recipients = local_cc_recipients.local()
local_cc_recipients = local_cc_recipients.values_list("pk", flat=True)
inbox_items = []
......@@ -457,6 +457,13 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
else:
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_url)
elif isinstance(r, dict) and r["type"] == "actor_inbox":
actor = r["actor"]
urls.append(actor.fid)
if actor.is_local:
local_recipients.add(actor)
else:
remote_inbox_urls.add(actor.inbox_url)
elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors
......
......@@ -301,6 +301,38 @@ CONTEXTS = [
}
},
},
{
"shortId": "LITEPUB",
"contextUrl": None,
"documentUrl": "http://litepub.social/ns",
"document": {
# from https://ap.thequietplace.social/schemas/litepub-0.1.jsonld
"@context": {
"Emoji": "toot:Emoji",
"Hashtag": "as:Hashtag",
"PropertyValue": "schema:PropertyValue",
"atomUri": "ostatus:atomUri",
"conversation": {"@id": "ostatus:conversation", "@type": "@id"},
"discoverable": "toot:discoverable",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"ostatus": "http://ostatus.org#",
"schema": "http://schema.org#",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value",
"sensitive": "as:sensitive",
"litepub": "http://litepub.social/ns#",
"invisible": "litepub:invisible",
"directMessage": "litepub:directMessage",
"listMessage": {"@id": "litepub:listMessage", "@type": "@id"},
"oauthRegistrationEndpoint": {
"@id": "litepub:oauthRegistrationEndpoint",
"@type": "@id",
},
"EmojiReact": "litepub:EmojiReact",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
}
},
},
]
CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS}
......@@ -332,3 +364,4 @@ AS = NS(CONTEXTS_BY_ID["AS"])
LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"])
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])
......@@ -125,7 +125,8 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
self.domain = models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME
)[0]
self.save(update_fields=["domain"])
self.fid = "https://{}/actors/{}".format(self.domain, self.preferred_username)
self.save(update_fields=["domain", "fid"])
if not create:
if extracted and hasattr(extracted, "pk"):
extracted.actor = self
......@@ -166,7 +167,9 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "music.Library"
class Params:
local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True)
)
@registry.register
......
......@@ -17,6 +17,10 @@ def cached_contexts(loader):
for cached in contexts.CONTEXTS:
if url == cached["documentUrl"]:
return cached
if cached["shortId"] == "LITEPUB" and "/schemas/litepub-" in url:
# XXX UGLY fix for pleroma because they host their schema
# under each instance domain, which makes caching harder
return cached
return loader(url, *args, **kwargs)
return load
......@@ -29,18 +33,19 @@ def get_document_loader():
return cached_contexts(loader)
def expand(doc, options=None, insert_fw_context=True):
def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]):
options = options or {}
options.setdefault("documentLoader", get_document_loader())
if isinstance(doc, str):
doc = options["documentLoader"](doc)["document"]
if insert_fw_context:
fw = contexts.CONTEXTS_BY_ID["FW"]["documentUrl"]
for context_name in default_contexts:
ctx = contexts.CONTEXTS_BY_ID[context_name]["documentUrl"]
try:
insert_context(fw, doc)
insert_context(ctx, doc)
except KeyError:
# probably an already expanded document
pass
result = pyld.jsonld.expand(doc, options=options)
try:
# jsonld.expand returns a list, which is useless for us
......
......@@ -443,26 +443,29 @@ class Activity(models.Model):
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
object_id = models.IntegerField(null=True)
object_id = models.IntegerField(null=True, blank=True)
object_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
target_id = models.IntegerField(null=True)
target_id = models.IntegerField(null=True, blank=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
related_object_id = models.IntegerField(null=True)
related_object_id = models.IntegerField(null=True, blank=True)
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="related_objecting_activities",
)
......
......@@ -451,3 +451,35 @@ def inbox_delete_actor(payload, context):
logger.warn("Cannot delete actor %s, no matching object found", actor.fid)
return
actor.delete()
@inbox.register({"type": "Flag"})
def inbox_flag(payload, context):
serializer = serializers.FlagSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.debug(
"Discarding invalid report from {}: %s",
context["actor"].fid,
serializer.errors,
)
return
report = serializer.save()
return {"object": report.target, "related_object": report}
@outbox.register({"type": "Flag"})
def outbox_flag(context):
report = context["report"]
actor = actors.get_service_actor()
serializer = serializers.FlagSerializer(report)
yield {
"type": "Flag",
"actor": actor,
"payload": with_recipients(
serializer.data,
# Mastodon requires the report to be sent to the reported actor inbox
# (and not the shared inbox)
to=[{"type": "actor_inbox", "actor": report.target_owner}],
),
}
......@@ -2,6 +2,7 @@ import logging
import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
......@@ -9,6 +10,9 @@ from rest_framework import serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import models as common_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
......@@ -36,13 +40,20 @@ class MediaSerializer(jsonld.JsonLdSerializer):
def __init__(self, *args, **kwargs):
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
super().__init__(*args, **kwargs)
self.fields["mediaType"].required = not self.allow_empty_mimetype
self.fields["mediaType"].allow_null = self.allow_empty_mimetype
def validate_mediaType(self, v):
if not self.allowed_mimetypes:
# no restrictions
return v
if self.allow_empty_mimetype and not v:
return None
for mt in self.allowed_mimetypes:
if mt.endswith("/*"):
if v.startswith(mt.replace("*", "")):
return v
......@@ -147,7 +158,10 @@ class ActorSerializer(jsonld.JsonLdSerializer):
publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False)
icon = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
class Meta:
......@@ -294,7 +308,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file(
actor,
"attachment_icon",
{"url": new_value["url"], "mimetype": new_value["mediaType"]}
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
)
......@@ -1030,7 +1044,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
return validated_data
# create the attachment by hand so it can be attached as the cover
validated_data["attachment_cover"] = common_models.Attachment.objects.create(
mimetype=attachment_cover["mediaType"],
mimetype=attachment_cover.get("mediaType"),
url=attachment_cover["url"],
actor=instance.attributed_to,
)
......@@ -1048,7 +1062,10 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
class ArtistSerializer(MusicEntitySerializer):
image = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "name"),
......@@ -1094,7 +1111,10 @@ class AlbumSerializer(MusicEntitySerializer):
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
# XXX: 1.0 rename to image
cover = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "title"),
......@@ -1172,7 +1192,10 @@ class TrackSerializer(MusicEntitySerializer):
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
image = ImageSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
......@@ -1437,6 +1460,85 @@ class ActorDeleteSerializer(jsonld.JsonLdSerializer):
jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)}
class FlagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Flag])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
content = serializers.CharField(required=False, allow_null=True, allow_blank=True)
actor = serializers.URLField(max_length=500)
type = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"content": jsonld.first_val(contexts.AS.content),
"actor": jsonld.first_id(contexts.AS.actor),
"type": jsonld.raw(contexts.AS.tag),
}
def validate_object(self, v):
try:
return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Unknown id {} for reported object".format(v)
)
def validate_type(self, tags):
if tags:
for tag in tags:
if tag["name"] in dict(moderation_models.REPORT_TYPES):
return tag["name"]
return "other"
def validate_actor(self, v):
try:
return models.Actor.objects.get(fid=v, domain=self.context["actor"].domain)
except models.Actor.DoesNotExist:
raise serializers.ValidationError("Invalid actor")
def validate(self, data):
validated_data = super().validate(data)
return validated_data
def create(self, validated_data):
kwargs = {
"target": validated_data["object"],
"target_owner": moderation_serializers.get_target_owner(
validated_data["object"]
),
"target_state": moderation_serializers.get_target_state(
validated_data["object"]
),
"type": validated_data.get("type", "other"),
"summary": validated_data.get("content"),
"submitter": validated_data["actor"],
}
report, created = moderation_models.Report.objects.update_or_create(
fid=validated_data["id"], defaults=kwargs,
)
moderation_signals.report_created.send(sender=None, report=report)
return report
def to_representation(self, instance):
d = {
"type": "Flag",
"id": instance.get_federation_id(),
"actor": actors.get_service_actor().fid,
"object": [instance.target.fid],
"content": instance.summary,
"tag": [repr_tag(instance.type)],
}
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
class NodeInfoLinkSerializer(serializers.Serializer):
href = serializers.URLField()
rel = serializers.URLField()
......
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):
......
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 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()