Fix #1038: Federated reports
This commit is contained in:
parent
40720328d7
commit
d9afed5067
34 changed files with 985 additions and 76 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
63
api/funkwhale_api/federation/spa_views.py
Normal file
63
api/funkwhale_api/federation/spa_views.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import middleware
|
||||
from funkwhale_api.common import utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def actor_detail_username(request, username, redirect_to_ap):
|
||||
validator = federation_utils.get_actor_data_from_username
|
||||
try:
|
||||
username_data = validator(username)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
|
||||
queryset = (
|
||||
models.Actor.objects.filter(
|
||||
preferred_username__iexact=username_data["username"]
|
||||
)
|
||||
.local()
|
||||
.select_related("attachment_icon")
|
||||
)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Actor.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
obj_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("actor_detail", kwargs={"username": obj.preferred_username}),
|
||||
)
|
||||
metas = [
|
||||
{"tag": "meta", "property": "og:url", "content": obj_url},
|
||||
{"tag": "meta", "property": "og:title", "content": obj.display_name},
|
||||
{"tag": "meta", "property": "og:type", "content": "profile"},
|
||||
]
|
||||
|
||||
if obj.attachment_icon:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "og:image",
|
||||
"content": obj.attachment_icon.download_url_medium_square_crop,
|
||||
}
|
||||
)
|
||||
|
||||
if preferences.get("federation__enabled"):
|
||||
metas.append(
|
||||
{
|
||||
"tag": "link",
|
||||
"rel": "alternate",
|
||||
"type": "application/activity+json",
|
||||
"href": obj.fid,
|
||||
}
|
||||
)
|
||||
|
||||
return metas
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import html.parser
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import CharField, Q, Value
|
||||
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.moderation import mrf
|
||||
|
|
@ -203,7 +207,7 @@ def find_alternate(response_text):
|
|||
return parser.result
|
||||
|
||||
|
||||
def should_redirect_ap_to_html(accept_header):
|
||||
def should_redirect_ap_to_html(accept_header, default=True):
|
||||
if not accept_header:
|
||||
return False
|
||||
|
||||
|
|
@ -223,4 +227,43 @@ def should_redirect_ap_to_html(accept_header):
|
|||
if ct in no_redirect_headers:
|
||||
return False
|
||||
|
||||
return True
|
||||
return default
|
||||
|
||||
|
||||
FID_MODEL_LABELS = [
|
||||
"music.Artist",
|
||||
"music.Album",
|
||||
"music.Track",
|
||||
"music.Library",
|
||||
"music.Upload",
|
||||
"federation.Actor",
|
||||
]
|
||||
|
||||
|
||||
def get_object_by_fid(fid, local=None):
|
||||
|
||||
if local is True:
|
||||
parsed = urllib.parse.urlparse(fid)
|
||||
if parsed.netloc != settings.FEDERATION_HOSTNAME:
|
||||
raise ObjectDoesNotExist()
|
||||
|
||||
models = [apps.get_model(*l.split(".")) for l in FID_MODEL_LABELS]
|
||||
|
||||
def get_qs(model):
|
||||
return (
|
||||
model.objects.all()
|
||||
.filter(fid=fid)
|
||||
.annotate(__type=Value(model._meta.label, output_field=CharField()))
|
||||
.values("fid", "__type")
|
||||
)
|
||||
|
||||
qs = get_qs(models[0])
|
||||
for m in models[1:]:
|
||||
qs = qs.union(get_qs(m))
|
||||
|
||||
result = qs.order_by("fid").first()
|
||||
|
||||
if not result:
|
||||
raise ObjectDoesNotExist()
|
||||
|
||||
return apps.get_model(*result["__type"].split(".")).objects.get(fid=fid)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
|
|||
|
||||
class Params:
|
||||
anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email"))
|
||||
local = factory.Trait(fid=None)
|
||||
assigned = factory.Trait(
|
||||
assigned_to=factory.SubFactory(federation_factories.ActorFactory)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -194,6 +194,27 @@ TARGET_CONFIG = {
|
|||
TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG)
|
||||
|
||||
|
||||
def get_target_state(target):
|
||||
state = {}
|
||||
target_state_serializer = state_serializers[target._meta.label]
|
||||
|
||||
state = target_state_serializer(target).data
|
||||
# freeze target type/id in JSON so even if the corresponding object is deleted
|
||||
# we can have the info and display it in the frontend
|
||||
target_data = TARGET_FIELD.to_representation(target)
|
||||
state["_target"] = json.loads(json.dumps(target_data, cls=DjangoJSONEncoder))
|
||||
|
||||
if "fid" in state:
|
||||
state["domain"] = urllib.parse.urlparse(state["fid"]).hostname
|
||||
|
||||
state["is_local"] = (
|
||||
state.get("domain", settings.FEDERATION_HOSTNAME)
|
||||
== settings.FEDERATION_HOSTNAME
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
target = TARGET_FIELD
|
||||
|
||||
|
|
@ -234,29 +255,7 @@ class ReportSerializer(serializers.ModelSerializer):
|
|||
return validated_data
|
||||
|
||||
def create(self, validated_data):
|
||||
target_state_serializer = state_serializers[
|
||||
validated_data["target"]._meta.label
|
||||
]
|
||||
|
||||
validated_data["target_state"] = target_state_serializer(
|
||||
validated_data["target"]
|
||||
).data
|
||||
# freeze target type/id in JSON so even if the corresponding object is deleted
|
||||
# we can have the info and display it in the frontend
|
||||
target_data = self.fields["target"].to_representation(validated_data["target"])
|
||||
validated_data["target_state"]["_target"] = json.loads(
|
||||
json.dumps(target_data, cls=DjangoJSONEncoder)
|
||||
)
|
||||
|
||||
if "fid" in validated_data["target_state"]:
|
||||
validated_data["target_state"]["domain"] = urllib.parse.urlparse(
|
||||
validated_data["target_state"]["fid"]
|
||||
).hostname
|
||||
|
||||
validated_data["target_state"]["is_local"] = (
|
||||
validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME)
|
||||
== settings.FEDERATION_HOSTNAME
|
||||
)
|
||||
validated_data["target_state"] = get_target_state(validated_data["target"])
|
||||
validated_data["target_owner"] = get_target_owner(validated_data["target"])
|
||||
r = super().create(validated_data)
|
||||
tasks.signals.report_created.send(sender=None, report=r)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ from rest_framework import response
|
|||
from rest_framework import status
|
||||
from rest_framework import viewsets
|
||||
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
||||
|
|
@ -66,4 +69,13 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
|
|||
submitter = None
|
||||
if self.request.user.is_authenticated:
|
||||
submitter = self.request.user.actor
|
||||
serializer.save(submitter=submitter)
|
||||
report = serializer.save(submitter=submitter)
|
||||
forward = self.request.data.get("forward", False)
|
||||
if (
|
||||
forward
|
||||
and report.target
|
||||
and report.target_owner
|
||||
and hasattr(report.target, "fid")
|
||||
and not federation_utils.is_local(report.target.fid)
|
||||
):
|
||||
routes.outbox.dispatch({"type": "Flag"}, context={"report": report})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from django.urls import reverse
|
|||
from django.db.models import Q
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import middleware
|
||||
from funkwhale_api.common import utils
|
||||
from funkwhale_api.playlists import models as playlists_models
|
||||
|
||||
|
|
@ -25,12 +26,16 @@ def get_twitter_card_metas(type, id):
|
|||
]
|
||||
|
||||
|
||||
def library_track(request, pk):
|
||||
def library_track(request, pk, redirect_to_ap):
|
||||
queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist")
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Track.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
|
||||
track_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_track", kwargs={"pk": obj.pk}),
|
||||
|
|
@ -114,12 +119,16 @@ def library_track(request, pk):
|
|||
return metas
|
||||
|
||||
|
||||
def library_album(request, pk):
|
||||
def library_album(request, pk, redirect_to_ap):
|
||||
queryset = models.Album.objects.filter(pk=pk).select_related("artist")
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Album.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
|
||||
album_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_album", kwargs={"pk": obj.pk}),
|
||||
|
|
@ -182,12 +191,16 @@ def library_album(request, pk):
|
|||
return metas
|
||||
|
||||
|
||||
def library_artist(request, pk):
|
||||
def library_artist(request, pk, redirect_to_ap):
|
||||
queryset = models.Artist.objects.filter(pk=pk)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Artist.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
|
||||
artist_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}),
|
||||
|
|
@ -242,7 +255,7 @@ def library_artist(request, pk):
|
|||
return metas
|
||||
|
||||
|
||||
def library_playlist(request, pk):
|
||||
def library_playlist(request, pk, redirect_to_ap):
|
||||
queryset = playlists_models.Playlist.objects.filter(pk=pk, privacy_level="everyone")
|
||||
try:
|
||||
obj = queryset.get()
|
||||
|
|
@ -294,12 +307,16 @@ def library_playlist(request, pk):
|
|||
return metas
|
||||
|
||||
|
||||
def library_library(request, uuid):
|
||||
def library_library(request, uuid, redirect_to_ap):
|
||||
queryset = models.Library.objects.filter(uuid=uuid)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Library.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
|
||||
library_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue