diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py
index 8c9fe51494418b848d8b4eb513a8c6829089cfaf..7b4b5e169b0546c21d4704d24aa16dfadfe2720f 100644
--- a/api/config/spa_urls.py
+++ b/api/config/spa_urls.py
@@ -1,6 +1,7 @@
 from django import urls
 
 from funkwhale_api.audio import spa_views as audio_spa_views
+from funkwhale_api.federation import spa_views as federation_spa_views
 from funkwhale_api.music import spa_views
 
 
@@ -36,4 +37,9 @@ urlpatterns = [
         audio_spa_views.channel_detail_username,
         name="channel_detail",
     ),
+    urls.re_path(
+        r"^@(?P<username>[^/]+)/?$",
+        federation_spa_views.actor_detail_username,
+        name="actor_detail",
+    ),
 ]
diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py
index 30811383628a08a8aa62f0ff431b6561fdc3aec6..bdf700f786fdc47d18c7b015f10a7783e72ed500 100644
--- a/api/funkwhale_api/audio/models.py
+++ b/api/funkwhale_api/audio/models.py
@@ -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)
diff --git a/api/funkwhale_api/audio/spa_views.py b/api/funkwhale_api/audio/spa_views.py
index c76669d39ece58f31983d89deaaf2be35ed92184..32dc7f585a5a8614e180c537733fd93a580f7e75 100644
--- a/api/funkwhale_api/audio/spa_views.py
+++ b/api/funkwhale_api/audio/spa_views.py
@@ -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)
diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py
index 6122adbcab26c91a0657494a8c2ee7a25db55831..201cd2ec84b29c433c62ec10553fa3f8c3487be0 100644
--- a/api/funkwhale_api/common/middleware.py
+++ b/api/funkwhale_api/common/middleware.py
@@ -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
 
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index b8d04164c120a50b31ced79a4b71116cd705e397..7d9d25a2de0c31dddddf9c6dc6dd7c0545b69ced 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -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
diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py
index b3fc112f0e0c233595ba9a63d4af9bf6bee73d2a..3e61c03fb0c7e037a8cc4b7dd9de07b1ecf2542b 100644
--- a/api/funkwhale_api/federation/contexts.py
+++ b/api/funkwhale_api/federation/contexts.py
@@ -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"])
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index e91d8dac994a458e2792480aa34274afc4e445aa..97158582d089bf3b085ab14b7b02da6a0b2411b5 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -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
diff --git a/api/funkwhale_api/federation/jsonld.py b/api/funkwhale_api/federation/jsonld.py
index 450490c910ff072927c1b2311aa9b45f72700359..05a4386062bc448e2e2c1b381dfac81d1f297865 100644
--- a/api/funkwhale_api/federation/jsonld.py
+++ b/api/funkwhale_api/federation/jsonld.py
@@ -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
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 9514f203a8051c5d7920e6feab59048df13d06b6..3579369557f6a08cb37de1b40f471ec7429d1ed8 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -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",
     )
diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py
index 32a8357dba613f36aafc0ef7ee5c17505fbb9310..70f312d1a644fdbed58affd65ea18c64227ae145 100644
--- a/api/funkwhale_api/federation/routes.py
+++ b/api/funkwhale_api/federation/routes.py
@@ -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}],
+        ),
+    }
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 2adbcbec40252ebd004d781a0cee79f38b3d4b72..08b51a6302c9e59c6bf83b412b857252de884507 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -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()
diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py
index 0d922d3258733ac431ee15855a21537a8217faa8..b69c486682bf06cbcba520ecf2c62d8c662e0d84 100644
--- a/api/funkwhale_api/federation/signing.py
+++ b/api/funkwhale_api/federation/signing.py
@@ -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):
diff --git a/api/funkwhale_api/federation/spa_views.py b/api/funkwhale_api/federation/spa_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..af7e210cf996c2ee56d56bb39807d79f5455bf72
--- /dev/null
+++ b/api/funkwhale_api/federation/spa_views.py
@@ -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
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index 8070b1310306acf04d2902e9aa6363b5895d66a9..b4f63e680e0042102d5372ca703e1e1bd23a6018 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -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)
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
index b426a6cea2a4d36ceca75aa56726c5a9f75c6a0d..96e6b5d1169bd57b627e13a0e8dc45a3715977c1 100644
--- a/api/funkwhale_api/moderation/factories.py
+++ b/api/funkwhale_api/moderation/factories.py
@@ -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)
         )
diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py
index 81e5846bb4a9c5c3e887a1a6a0cb9a92bd36b321..7d772d39e1d4a6de5318b94c2323a441249f6af3 100644
--- a/api/funkwhale_api/moderation/serializers.py
+++ b/api/funkwhale_api/moderation/serializers.py
@@ -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)
diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py
index 67de68001d507ce688ae20bba33b050fdc08e026..b3a91594a402dad40735d3a5f4b2f0097eae0fe9 100644
--- a/api/funkwhale_api/moderation/views.py
+++ b/api/funkwhale_api/moderation/views.py
@@ -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})
diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py
index 073f5bb965d4391930d5358327fc80d68a02c77b..7997a3c07a89a68073a2d62dddfadf80240f4f6a 100644
--- a/api/funkwhale_api/music/spa_views.py
+++ b/api/funkwhale_api/music/spa_views.py
@@ -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}),
diff --git a/api/setup.cfg b/api/setup.cfg
index f50bd547391841695945d2c6f2d5fd1f3fb5e077..581396c37acad5639b67a8fade0e5de36fe7b58c 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -18,7 +18,11 @@ env =
     EMAIL_CONFIG=consolemail://
     CELERY_BROKER_URL=memory://
     CELERY_TASK_ALWAYS_EAGER=True
+    FUNKWHALE_HOSTNAME_SUFFIX=
+    FUNKWHALE_HOSTNAME_PREFIX=
+    FUNKWHALE_HOSTNAME=test.federation
     FEDERATION_HOSTNAME=test.federation
+    FUNKWHALE_URL=https://test.federation
     DEBUG_TOOLBAR_ENABLED=False
     DEBUG=False
     WEAK_PASSWORDS=True
diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py
index d3908e3f9f45d3417f634cfd94fb3138807a4df5..88e8d05848fa83e1052af1be5672dbc95f8ff617 100644
--- a/api/tests/common/test_middleware.py
+++ b/api/tests/common/test_middleware.py
@@ -8,6 +8,7 @@ from funkwhale_api.federation import utils as federation_utils
 
 from funkwhale_api.common import middleware
 from funkwhale_api.common import throttling
+from funkwhale_api.common import utils
 
 
 def test_spa_fallback_middleware_no_404(mocker):
@@ -142,11 +143,11 @@ def test_get_spa_html_from_disk(tmp_path):
 def test_get_route_head_tags(mocker, settings):
     match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock())
     resolve = mocker.patch("django.urls.resolve", return_value=match)
-    request = mocker.Mock(path="/tracks/42")
+    request = mocker.Mock(path="/tracks/42", headers={})
     tags = middleware.get_request_head_tags(request)
 
     assert tags == match.func.return_value
-    match.func.assert_called_once_with(request, *[], **{"pk": 42})
+    match.func.assert_called_once_with(request, *[], redirect_to_ap=False, **{"pk": 42})
     resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF)
 
 
@@ -326,3 +327,90 @@ def test_rewrite_manifest_json_url_rewrite_default_url(mocker, settings):
         expected_url
     )
     assert response.content == expected_html.encode()
+
+
+def test_spa_middleware_handles_api_redirect(mocker):
+    get_response = mocker.Mock(return_value=mocker.Mock(status_code=404))
+    redirect_url = "/test"
+    mocker.patch.object(
+        middleware, "serve_spa", side_effect=middleware.ApiRedirect(redirect_url)
+    )
+    api_view = mocker.Mock()
+    match = mocker.Mock(args=["hello"], kwargs={"foo": "bar"}, func=api_view)
+    mocker.patch.object(middleware.urls, "resolve", return_value=match)
+
+    request = mocker.Mock(path="/")
+
+    m = middleware.SPAFallbackMiddleware(get_response)
+
+    response = m(request)
+
+    api_view.assert_called_once_with(request, "hello", foo="bar")
+    assert response == api_view.return_value
+
+
+@pytest.mark.parametrize(
+    "accept_header, expected",
+    [
+        ("text/html", False),
+        ("application/activity+json", True),
+        ("", False),
+        ("noop", False),
+        ("text/html,application/activity+json", False),
+        ("application/activity+json,text/html", True),
+    ],
+)
+def test_get_request_head_tags_calls_view_with_proper_arg_when_accept_header_set(
+    accept_header, expected, mocker, fake_request
+):
+    request = fake_request.get("/", HTTP_ACCEPT=accept_header)
+
+    view = mocker.Mock()
+    match = mocker.Mock(args=["hello"], kwargs={"foo": "bar"}, func=view)
+    mocker.patch.object(middleware.urls, "resolve", return_value=match)
+
+    assert middleware.get_request_head_tags(request) == view.return_value
+    view.assert_called_once_with(request, "hello", foo="bar", redirect_to_ap=expected)
+
+
+@pytest.mark.parametrize(
+    "factory_name, factory_kwargs, route_name, route_arg_name, route_arg",
+    [
+        (
+            "federation.Actor",
+            {"local": True},
+            "actor_detail",
+            "username",
+            "preferred_username",
+        ),
+        (
+            "audio.Channel",
+            {"local": True},
+            "channel_detail",
+            "username",
+            "actor.preferred_username",
+        ),
+        ("music.Artist", {}, "library_artist", "pk", "pk",),
+        ("music.Album", {}, "library_album", "pk", "pk",),
+        ("music.Track", {}, "library_track", "pk", "pk",),
+        ("music.Library", {}, "library_library", "uuid", "uuid",),
+    ],
+)
+def test_spa_views_raise_api_redirect_when_accept_json_set(
+    factory_name,
+    factory_kwargs,
+    route_name,
+    route_arg_name,
+    route_arg,
+    factories,
+    fake_request,
+):
+    obj = factories[factory_name](**factory_kwargs)
+    url = utils.spa_reverse(
+        route_name, kwargs={route_arg_name: utils.recursive_getattr(obj, route_arg)}
+    )
+    request = fake_request.get(url, HTTP_ACCEPT="application/activity+json")
+
+    with pytest.raises(middleware.ApiRedirect) as excinfo:
+        middleware.get_request_head_tags(request)
+    assert excinfo.value.url == obj.fid
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
index bac27efc542bf085a699461b14e8892acf29a009..89a25b22a19d004939376078bb71fe17914bc2ed 100644
--- a/api/tests/federation/test_activity.py
+++ b/api/tests/federation/test_activity.py
@@ -456,6 +456,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
         shared_inbox_url=remote_actor1.shared_inbox_url
     )
     remote_actor3 = factories["federation.Actor"](shared_inbox_url=None)
+    remote_actor4 = factories["federation.Actor"]()
 
     library = factories["music.Library"]()
     library_follower_local = factories["federation.LibraryFollow"](
@@ -491,6 +492,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
         activity.PUBLIC_ADDRESS,
         {"type": "followers", "target": library},
         {"type": "followers", "target": followed_actor},
+        {"type": "actor_inbox", "actor": remote_actor4},
     ]
 
     inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items(
@@ -511,6 +513,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
         [
             models.Delivery(inbox_url=remote_actor1.shared_inbox_url),
             models.Delivery(inbox_url=remote_actor3.inbox_url),
+            models.Delivery(inbox_url=remote_actor4.inbox_url),
             models.Delivery(inbox_url=library_follower_remote.inbox_url),
             models.Delivery(inbox_url=actor_follower_remote.inbox_url),
         ],
@@ -527,6 +530,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
         activity.PUBLIC_ADDRESS,
         library.followers_url,
         followed_actor.followers_url,
+        remote_actor4.fid,
     ]
 
     assert urls == expected_urls
diff --git a/api/tests/federation/test_jsonld.py b/api/tests/federation/test_jsonld.py
index 7bf906d50ca690e1c71818c136326b1e9866eb1c..92253f921407018afc319d84f31b88c3c8f31da6 100644
--- a/api/tests/federation/test_jsonld.py
+++ b/api/tests/federation/test_jsonld.py
@@ -67,6 +67,95 @@ def test_expand_no_external_request():
     assert doc == expected
 
 
+def test_expand_no_external_request_pleroma():
+    payload = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://pleroma.example/schemas/litepub-0.1.jsonld",
+            {"@language": "und"},
+        ],
+        "endpoints": {
+            "oauthAuthorizationEndpoint": "https://pleroma.example/oauth/authorize",
+            "oauthRegistrationEndpoint": "https://pleroma.example/api/v1/apps",
+            "oauthTokenEndpoint": "https://pleroma.example/oauth/token",
+            "sharedInbox": "https://pleroma.example/inbox",
+            "uploadMedia": "https://pleroma.example/api/ap/upload_media",
+        },
+        "followers": "https://pleroma.example/internal/fetch/followers",
+        "following": "https://pleroma.example/internal/fetch/following",
+        "id": "https://pleroma.example/internal/fetch",
+        "inbox": "https://pleroma.example/internal/fetch/inbox",
+        "invisible": True,
+        "manuallyApprovesFollowers": False,
+        "name": "Pleroma",
+        "preferredUsername": "internal.fetch",
+        "publicKey": {
+            "id": "https://pleroma.example/internal/fetch#main-key",
+            "owner": "https://pleroma.example/internal/fetch",
+            "publicKeyPem": "PEM",
+        },
+        "summary": "An internal service actor for this Pleroma instance.  No user-serviceable parts inside.",
+        "type": "Application",
+        "url": "https://pleroma.example/internal/fetch",
+    }
+
+    expected = {
+        contexts.AS.endpoints: [
+            {
+                contexts.AS.sharedInbox: [{"@id": "https://pleroma.example/inbox"}],
+                contexts.AS.oauthAuthorizationEndpoint: [
+                    {"@id": "https://pleroma.example/oauth/authorize"}
+                ],
+                contexts.LITEPUB.oauthRegistrationEndpoint: [
+                    {"@id": "https://pleroma.example/api/v1/apps"}
+                ],
+                contexts.AS.oauthTokenEndpoint: [
+                    {"@id": "https://pleroma.example/oauth/token"}
+                ],
+                contexts.AS.uploadMedia: [
+                    {"@id": "https://pleroma.example/api/ap/upload_media"}
+                ],
+            },
+        ],
+        contexts.AS.followers: [
+            {"@id": "https://pleroma.example/internal/fetch/followers"}
+        ],
+        contexts.AS.following: [
+            {"@id": "https://pleroma.example/internal/fetch/following"}
+        ],
+        "@id": "https://pleroma.example/internal/fetch",
+        "http://www.w3.org/ns/ldp#inbox": [
+            {"@id": "https://pleroma.example/internal/fetch/inbox"}
+        ],
+        contexts.LITEPUB.invisible: [{"@value": True}],
+        contexts.AS.manuallyApprovesFollowers: [{"@value": False}],
+        contexts.AS.name: [{"@language": "und", "@value": "Pleroma"}],
+        contexts.AS.summary: [
+            {
+                "@language": "und",
+                "@value": "An internal service actor for this Pleroma instance.  No user-serviceable parts inside.",
+            }
+        ],
+        contexts.AS.url: [{"@id": "https://pleroma.example/internal/fetch"}],
+        contexts.AS.preferredUsername: [
+            {"@language": "und", "@value": "internal.fetch"}
+        ],
+        contexts.SEC.publicKey: [
+            {
+                "@id": "https://pleroma.example/internal/fetch#main-key",
+                contexts.SEC.owner: [{"@id": "https://pleroma.example/internal/fetch"}],
+                contexts.SEC.publicKeyPem: [{"@language": "und", "@value": "PEM"}],
+            }
+        ],
+        "@type": [contexts.AS.Application],
+    }
+
+    doc = jsonld.expand(payload)
+
+    assert doc[contexts.AS.endpoints] == expected[contexts.AS.endpoints]
+    assert doc == expected
+
+
 def test_expand_remote_doc(r_mock):
     url = "https://noop/federation/actors/demo"
     payload = {
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index 587ccc33323d9135ed9d5918c89d4dd2ccffb79e..f63f82896d4559ae20e12088d70e5eabd1075672 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -8,6 +8,7 @@ from funkwhale_api.federation import (
     routes,
     serializers,
 )
+from funkwhale_api.moderation import serializers as moderation_serializers
 
 
 @pytest.mark.parametrize(
@@ -30,6 +31,7 @@ from funkwhale_api.federation import (
         ({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album),
         ({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track),
         ({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
+        ({"type": "Flag"}, routes.inbox_flag),
     ],
 )
 def test_inbox_routes(route, handler):
@@ -44,6 +46,7 @@ def test_inbox_routes(route, handler):
     "route,handler",
     [
         ({"type": "Accept"}, routes.outbox_accept),
+        ({"type": "Flag"}, routes.outbox_flag),
         ({"type": "Follow"}, routes.outbox_follow),
         ({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio),
         (
@@ -718,3 +721,69 @@ def test_inbox_delete_actor_doesnt_delete_local_actor(factories):
     )
     # actor should still be here!
     local_actor.refresh_from_db()
+
+
+@pytest.mark.parametrize(
+    "factory_name, factory_kwargs",
+    [
+        ("federation.Actor", {"local": True}),
+        ("music.Artist", {"local": True}),
+        ("music.Album", {"local": True}),
+        ("music.Track", {"local": True}),
+        ("music.Library", {"local": True}),
+    ],
+)
+def test_inbox_flag(factory_name, factory_kwargs, factories, mocker):
+    report_created_send = mocker.patch(
+        "funkwhale_api.moderation.signals.report_created.send"
+    )
+    actor = factories["federation.Actor"]()
+    target = factories[factory_name](**factory_kwargs)
+    payload = {
+        "type": "Flag",
+        "object": [target.fid],
+        "content": "Test report",
+        "id": "https://" + actor.domain_id + "/testid",
+        "actor": actor.fid,
+    }
+    serializer = serializers.ActivitySerializer(payload)
+
+    result = routes.inbox_flag(
+        serializer.data, context={"actor": actor, "raise_exception": True}
+    )
+
+    report = actor.reports.latest("id")
+
+    assert result == {"object": target, "related_object": report}
+    assert report.fid == payload["id"]
+    assert report.type == "other"
+    assert report.target == target
+    assert report.target_owner == moderation_serializers.get_target_owner(target)
+    assert report.target_state == moderation_serializers.get_target_state(target)
+
+    report_created_send.assert_called_once_with(sender=None, report=report)
+
+
+@pytest.mark.parametrize(
+    "factory_name, factory_kwargs",
+    [
+        ("federation.Actor", {"local": True}),
+        ("music.Artist", {"local": True}),
+        ("music.Album", {"local": True}),
+        ("music.Track", {"local": True}),
+        ("music.Library", {"local": True}),
+    ],
+)
+def test_outbox_flag(factory_name, factory_kwargs, factories, mocker):
+    target = factories[factory_name](**factory_kwargs)
+    report = factories["moderation.Report"](
+        target=target, local=True, target_owner=factories["federation.Actor"]()
+    )
+
+    activity = list(routes.outbox_flag({"report": report}))[0]
+
+    serializer = serializers.FlagSerializer(report)
+    expected = serializer.data
+    expected["to"] = [{"type": "actor_inbox", "actor": report.target_owner}]
+    assert activity["payload"] == expected
+    assert activity["actor"] == actors.get_service_actor()
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index f2fd68eb306f79939fa4b4f4360b47fd6f788947..e203e0aff6ebbb00de21bd8d19573fc311e31c56 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -6,12 +6,14 @@ from django.core.paginator import Paginator
 from django.utils import timezone
 
 from funkwhale_api.common import utils as common_utils
+from funkwhale_api.federation import actors
 from funkwhale_api.federation import contexts
 from funkwhale_api.federation import keys
 from funkwhale_api.federation import jsonld
 from funkwhale_api.federation import models
 from funkwhale_api.federation import serializers
 from funkwhale_api.federation import utils
+from funkwhale_api.moderation import serializers as moderation_serializers
 from funkwhale_api.music import licenses
 
 
@@ -70,6 +72,36 @@ def test_actor_serializer_from_ap(db):
     assert actor.attachment_icon.mimetype == payload["icon"]["mediaType"]
 
 
+def test_actor_serializer_from_ap_no_icon_mediaType(db):
+    private, public = keys.get_key_pair()
+    actor_url = "https://test.federation/actor"
+    payload = {
+        "@context": jsonld.get_default_context_fw(),
+        "id": actor_url,
+        "type": "Person",
+        "inbox": "https://test.com/inbox",
+        "following": "https://test.com/following",
+        "followers": "https://test.com/followers",
+        "preferredUsername": "test",
+        "manuallyApprovesFollowers": True,
+        "url": "http://hello.world/path",
+        "publicKey": {
+            "publicKeyPem": public.decode("utf-8"),
+            "owner": actor_url,
+            "id": actor_url + "#main-key",
+        },
+        "endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"},
+        "icon": {"type": "Image", "url": "https://image.example/image.png"},
+    }
+
+    serializer = serializers.ActorSerializer(data=payload)
+    assert serializer.is_valid(raise_exception=True)
+    actor = serializer.save()
+
+    assert actor.attachment_icon.url == payload["icon"]["url"]
+    assert actor.attachment_icon.mimetype is None
+
+
 def test_actor_serializer_only_mandatory_field_from_ap(db):
     payload = {
         "@context": jsonld.get_default_context(),
@@ -1477,3 +1509,44 @@ def test_channel_create_upload_serializer(factories):
     serializer = serializers.ChannelCreateUploadSerializer(upload)
 
     assert serializer.data == expected
+
+
+def test_report_serializer_from_ap_create(factories, faker, now, mocker):
+    actor = factories["federation.Actor"]()
+    obj = factories["music.Artist"](local=True)
+    payload = {
+        "@context": jsonld.get_default_context(),
+        "type": "Flag",
+        "id": "https://test.report",
+        "actor": actor.fid,
+        "content": "hello world",
+        "object": [obj.fid],
+        "tag": [{"type": "Hashtag", "name": "#offensive_content"}],
+    }
+    serializer = serializers.FlagSerializer(data=payload, context={"actor": actor})
+    assert serializer.is_valid(raise_exception=True) is True
+
+    report = serializer.save()
+
+    assert report.fid == payload["id"]
+    assert report.summary == payload["content"]
+    assert report.submitter == actor
+    assert report.target == obj
+    assert report.target_state == moderation_serializers.get_target_state(obj)
+    assert report.target_owner == moderation_serializers.get_target_owner(obj)
+    assert report.type == "offensive_content"
+
+
+def test_report_serializer_to_ap(factories):
+    report = factories["moderation.Report"](local=True)
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "type": "Flag",
+        "id": report.fid,
+        "actor": actors.get_service_actor().fid,
+        "content": report.summary,
+        "object": [report.target.fid],
+        "tag": [{"type": "Hashtag", "name": "#{}".format(report.type)}],
+    }
+    serializer = serializers.FlagSerializer(report)
+    assert serializer.data == expected
diff --git a/api/tests/federation/test_spa_views.py b/api/tests/federation/test_spa_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..f728419961c1bb325a9e8fc3fbc1ec5dffa064d3
--- /dev/null
+++ b/api/tests/federation/test_spa_views.py
@@ -0,0 +1,36 @@
+from funkwhale_api.common import utils
+
+
+def test_channel_detail(spa_html, no_api_auth, client, factories, settings):
+    icon = factories["common.Attachment"]()
+    actor = factories["federation.Actor"](local=True, attachment_icon=icon)
+    url = "/@{}".format(actor.preferred_username)
+
+    response = client.get(url)
+
+    assert response.status_code == 200
+    expected_metas = [
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+        },
+        {"tag": "meta", "property": "og:title", "content": actor.display_name},
+        {"tag": "meta", "property": "og:type", "content": "profile"},
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": actor.attachment_icon.download_url_medium_square_crop,
+        },
+        {
+            "tag": "link",
+            "rel": "alternate",
+            "type": "application/activity+json",
+            "href": actor.fid,
+        },
+    ]
+
+    metas = utils.parse_meta(response.content.decode())
+
+    # we only test our custom metas, not the default ones
+    assert metas[: len(expected_metas)] == expected_metas
diff --git a/api/tests/federation/test_third_party_activitypub.py b/api/tests/federation/test_third_party_activitypub.py
new file mode 100644
index 0000000000000000000000000000000000000000..34b09c891a69d898f5140f2054d339e6cfb0e7fd
--- /dev/null
+++ b/api/tests/federation/test_third_party_activitypub.py
@@ -0,0 +1,58 @@
+from funkwhale_api.federation import serializers
+
+
+def test_pleroma_actor_from_ap(factories):
+
+    payload = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://test.federation/schemas/litepub-0.1.jsonld",
+            {"@language": "und"},
+        ],
+        "endpoints": {
+            "oauthAuthorizationEndpoint": "https://test.federation/oauth/authorize",
+            "oauthRegistrationEndpoint": "https://test.federation/api/v1/apps",
+            "oauthTokenEndpoint": "https://test.federation/oauth/token",
+            "sharedInbox": "https://test.federation/inbox",
+            "uploadMedia": "https://test.federation/api/ap/upload_media",
+        },
+        "followers": "https://test.federation/internal/fetch/followers",
+        "following": "https://test.federation/internal/fetch/following",
+        "id": "https://test.federation/internal/fetch",
+        "inbox": "https://test.federation/internal/fetch/inbox",
+        "invisible": True,
+        "manuallyApprovesFollowers": False,
+        "name": "Pleroma",
+        "preferredUsername": "internal.fetch",
+        "publicKey": {
+            "id": "https://test.federation/internal/fetch#main-key",
+            "owner": "https://test.federation/internal/fetch",
+            "publicKeyPem": "PEM",
+        },
+        "summary": "An internal service actor for this Pleroma instance.  No user-serviceable parts inside.",
+        "type": "Application",
+        "url": "https://test.federation/internal/fetch",
+    }
+
+    serializer = serializers.ActorSerializer(data=payload)
+    assert serializer.is_valid(raise_exception=True)
+    actor = serializer.save()
+
+    assert actor.fid == payload["id"]
+    assert actor.url == payload["url"]
+    assert actor.inbox_url == payload["inbox"]
+    assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"]
+    assert actor.outbox_url is None
+    assert actor.following_url == payload["following"]
+    assert actor.followers_url == payload["followers"]
+    assert actor.followers_url == payload["followers"]
+    assert actor.type == payload["type"]
+    assert actor.preferred_username == payload["preferredUsername"]
+    assert actor.name == payload["name"]
+    assert actor.summary_obj.text == payload["summary"]
+    assert actor.summary_obj.content_type == "text/html"
+    assert actor.fid == payload["url"]
+    assert actor.manually_approves_followers is payload["manuallyApprovesFollowers"]
+    assert actor.private_key is None
+    assert actor.public_key == payload["publicKey"]["publicKeyPem"]
+    assert actor.domain_id == "test.federation"
diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py
index 9502fb6c36c234f641b6cd4a9e2e0e338d0f2336..6ba9ccfaeceb747f3e6a18e14b717652d3c0d283 100644
--- a/api/tests/federation/test_utils.py
+++ b/api/tests/federation/test_utils.py
@@ -1,6 +1,8 @@
 from rest_framework import serializers
 import pytest
 
+from django.core.exceptions import ObjectDoesNotExist
+
 from funkwhale_api.federation import exceptions, utils
 
 
@@ -172,3 +174,36 @@ def test_local_qs(factory_name, fids, kwargs, expected_indexes, factories, setti
 
     expected_objs = [obj for i, obj in enumerate(objs) if i in expected_indexes]
     assert list(result) == expected_objs
+
+
+def test_get_obj_by_fid_not_found():
+    with pytest.raises(ObjectDoesNotExist):
+        utils.get_object_by_fid("http://test")
+
+
+def test_get_obj_by_fid_local_not_found(factories):
+    obj = factories["federation.Actor"](local=False)
+    with pytest.raises(ObjectDoesNotExist):
+        utils.get_object_by_fid(obj.fid, local=True)
+
+
+def test_get_obj_by_fid_local(factories):
+    obj = factories["federation.Actor"](local=True)
+    assert utils.get_object_by_fid(obj.fid, local=True) == obj
+
+
+@pytest.mark.parametrize(
+    "factory_name",
+    [
+        "federation.Actor",
+        "music.Artist",
+        "music.Album",
+        "music.Track",
+        "music.Upload",
+        "music.Library",
+    ],
+)
+def test_get_obj_by_fid(factory_name, factories):
+    obj = factories[factory_name]()
+    factories[factory_name]()
+    assert utils.get_object_by_fid(obj.fid) == obj
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 0b5759937445be16c6547cf055c641368d926c48..bd778778f3ec83c2bf81add7894d4c9c67bce018 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -354,20 +354,24 @@ def test_music_upload_detail_private_approved_follow(
 
 
 @pytest.mark.parametrize(
-    "accept_header,expected",
+    "accept_header,default,expected",
     [
-        ("text/html,application/xhtml+xml", True),
-        ("text/html,application/json", True),
-        ("", False),
-        (None, False),
-        ("application/json", False),
-        ("application/activity+json", False),
-        ("application/json,text/html", False),
-        ("application/activity+json,text/html", False),
+        ("text/html,application/xhtml+xml", True, True),
+        ("text/html,application/json", True, True),
+        ("", True, False),
+        (None, True, False),
+        ("application/json", True, False),
+        ("application/activity+json", True, False),
+        ("application/json,text/html", True, False),
+        ("application/activity+json,text/html", True, False),
+        ("unrelated/ct", True, True),
+        ("unrelated/ct", False, False),
     ],
 )
-def test_should_redirect_ap_to_html(accept_header, expected):
-    assert federation_utils.should_redirect_ap_to_html(accept_header) is expected
+def test_should_redirect_ap_to_html(accept_header, default, expected):
+    assert (
+        federation_utils.should_redirect_ap_to_html(accept_header, default) is expected
+    )
 
 
 def test_music_library_retrieve_redirects_to_html_if_header_set(
diff --git a/api/tests/moderation/test_views.py b/api/tests/moderation/test_views.py
index dba2281720d3a0e3c82214ee001ecdfec5bf9683..9f4196f961b3666a8658a75e205c15faaf81baf0 100644
--- a/api/tests/moderation/test_views.py
+++ b/api/tests/moderation/test_views.py
@@ -56,3 +56,22 @@ def test_create_report_anonymous(factories, api_client, no_api_auth):
     assert response.status_code == 201
     report = models.Report.objects.latest("id")
     assert report.submitter_email == data["submitter_email"]
+
+
+def test_create_report_and_forward(factories, api_client, no_api_auth, mocker):
+    dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
+    target = factories["music.Artist"](attributed=True)
+    url = reverse("api:v1:moderation:reports-list")
+    data = {
+        "target": {"type": "artist", "id": target.pk},
+        "summary": "Test report",
+        "type": "illegal_content",
+        "submitter_email": "test@example.test",
+        "forward": True,
+    }
+    response = api_client.post(url, data, format="json")
+
+    assert response.status_code == 201
+    report = models.Report.objects.latest("id")
+
+    dispatch.assert_called_once_with({"type": "Flag"}, context={"report": report})
diff --git a/changes/changelog.d/1038.feature b/changes/changelog.d/1038.feature
new file mode 100644
index 0000000000000000000000000000000000000000..1e6913ab3caa3b9c4c84e4b24eb3001076f7a619
--- /dev/null
+++ b/changes/changelog.d/1038.feature
@@ -0,0 +1 @@
+Federated reports (#1038)
diff --git a/changes/notes.rst b/changes/notes.rst
index 5777f9c07e2055827d2a0a60dd571a5b9c99c93d..b1b9cc655eb2e384aa2ee847e601ec239eea61b3 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -27,6 +27,12 @@ the following instruction is present in your nginx configuration::
         add_header Service-Worker-Allowed "/";
     }
 
+Federated reports
+^^^^^^^^^^^^^^^^^
+
+It's now possible to send a copy of a report to the server hosting the reported object, in order to make moderation easier and more distributed.
+
+This feature is inspired by Mastodon's current design, and should work with at least Funkwhale and Mastodon servers.
 
 Improved search performance
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue
index 8746fa008adedbf98af223508bec17da4d55490d..058c4b5cbf651022fa8d288c5133ba6268e07af2 100644
--- a/front/src/components/mixins/Report.vue
+++ b/front/src/components/mixins/Report.vue
@@ -9,6 +9,7 @@ export default {
           label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}),
           target: {
             type: 'account',
+            _obj: account,
             full_username: account.full_username,
             label: account.full_username,
             typeLabel: this.$pgettext("*/*/*/Noun", 'Account'),
@@ -25,6 +26,7 @@ export default {
           target: {
             type: 'track',
             id: track.id,
+            _obj: track,
             label: track.title,
             typeLabel: this.$pgettext("*/*/*/Noun", 'Track'),
           }
@@ -39,6 +41,7 @@ export default {
             type: 'album',
             id: album.id,
             label: album.title,
+            _obj: album,
             typeLabel: this.$pgettext("*/*/*", 'Album'),
           }
         })
@@ -53,6 +56,7 @@ export default {
             type: 'artist',
             id: artist.id,
             label: artist.name,
+            _obj: artist,
             typeLabel: this.$pgettext("*/*/*/Noun", 'Artist'),
           }
         })
@@ -64,6 +68,7 @@ export default {
             type: 'playlist',
             id: playlist.id,
             label: playlist.name,
+            _obj: playlist,
             typeLabel: this.$pgettext("*/*/*", 'Playlist'),
           }
         })
@@ -75,6 +80,7 @@ export default {
             type: 'library',
             uuid: library.uuid,
             label: library.name,
+            _obj: library,
             typeLabel: this.$pgettext("*/*/*/Noun", 'Library'),
           }
         })
diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue
index 0f58712cd2a1d912b57f4874a54e558a4933636e..f53f6e99820a89a043556d0fd712ec62fba63299 100644
--- a/front/src/components/moderation/ReportModal.vue
+++ b/front/src/components/moderation/ReportModal.vue
@@ -46,6 +46,20 @@
           </p>
           <content-form field-id="report-summary" :rows="8" v-model="summary"></content-form>
         </div>
+        <div class="ui field" v-if="!isLocal">
+          <div class="ui checkbox">
+            <input id="report-forward" v-model="forward" type="checkbox">
+            <label for="report-forward">
+              <strong>
+                <translate :translate-params="{domain: targetDomain}" translate-context="*/*/Field.Label/Verb">Forward to %{ domain} </translate>
+              </strong>
+              <p>
+                <translate translate-context="*/*/Field,Help">Forward an anonymized copy of your report to the server hosting this element.</translate>
+              </p>
+            </label>
+          </div>
+        </div>
+        <div class="ui hidden divider"></div>
       </form>
       <div v-else-if="isLoadingReportTypes" class="ui inline active loader">
 
@@ -75,6 +89,12 @@ import {mapState} from 'vuex'
 
 import logger from '@/logging'
 
+function urlDomain(data) {
+  var    a      = document.createElement('a');
+         a.href = data;
+  return a.hostname;
+}
+
 export default {
   components: {
     ReportCategoryDropdown:  () => import(/* webpackChunkName: "reports" */ "@/components/moderation/ReportCategoryDropdown"),
@@ -90,6 +110,7 @@ export default {
       submitterEmail: '',
       category: null,
       reportTypes: [],
+      forward: false,
     }
   },
   computed: {
@@ -113,6 +134,19 @@ export default {
       }
 
       return this.allowedCategories.length > 0
+    },
+    targetDomain () {
+      if (!this.target._obj) {
+        return
+      }
+      let fid = this.target._obj.fid
+      if (!fid) {
+        return this.$store.getters['instance/domain']
+      }
+      return urlDomain(fid)
+    },
+    isLocal () {
+      return this.$store.getters['instance/domain'] === this.targetDomain
     }
   },
   methods: {
@@ -124,9 +158,10 @@ export default {
       let self = this
       self.isLoading = true
       let payload = {
-        target: this.target,
+        target: {...this.target, _obj: null},
         summary: this.summary,
         type: this.category,
+        forward: this.forward,
       }
       if (!this.$store.state.auth.authenticated) {
         payload.submitter_email = this.submitterEmail
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index a666af1ee089c62fb4e84789e0709b542c105b6c..2a8515ab95dca0e49ccb38305f4276cf82ede113 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -10,7 +10,7 @@ export default {
     momentLocale: 'en',
     lastDate: new Date(),
     maxMessages: 100,
-    messageDisplayDuration: 10000,
+    messageDisplayDuration: 5 * 1000,
     supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
     messages: [],
     theme: 'light',