Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 303-json-ld
  • 629-cookie-auth
  • 735-table-truncate
  • develop
  • domain-policies
  • live-streaming
  • master
  • webdav
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
50 results
Show changes
Showing
with 1276 additions and 250 deletions
# Generated by Django 5.1.6 on 2025-09-12 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("favorites", "0003_trackfavorite_actor_trackfavorite_fid_and_more"),
]
operations = [
migrations.AddField(
model_name="trackfavorite",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
max_length=30,
default="me",
),
),
]
import uuid
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
class TrackFavorite(models.Model): class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def viewable_by(self, actor):
if actor is None:
return self.filter(actor__user__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
instance_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__user__privacy_level="everyone")
)
class TrackFavorite(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey( actor = models.ForeignKey(
"users.User", related_name="track_favorites", on_delete=models.CASCADE "federation.Actor",
related_name="track_favorites",
on_delete=models.CASCADE,
null=False,
blank=False,
) )
privacy_level = fields.get_privacy_field()
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="track_favorites", on_delete=models.CASCADE Track, related_name="track_favorites", on_delete=models.CASCADE
) )
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "likes"
objects = TrackFavoriteQuerySet.as_manager()
class Meta: class Meta:
unique_together = ("track", "user") unique_together = ("track", "actor")
ordering = ("-creation_date",) ordering = ("-creation_date",)
@classmethod @classmethod
def add(cls, track, user): def add(cls, track, actor):
favorite, created = cls.objects.get_or_create(user=user, track=track) favorite, created = cls.objects.get_or_create(actor=actor, track=track)
return favorite return favorite
def get_activity_url(self): def get_activity_url(self):
return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk) return f"{self.actor.get_absolute_url()}/favorites/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
if not self.privacy_level:
self.privacy_level = self.actor.user.privacy_level
return super().save(**kwargs)
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models from . import models
...@@ -10,30 +10,40 @@ from . import models ...@@ -10,30 +10,40 @@ from . import models
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track") object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user") actor = federation_serializers.APIActorSerializer(read_only=True)
published = serializers.DateTimeField(source="creation_date") published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["id", "local_id", "object", "type", "actor", "published"] fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return "Like" return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True) track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date") fields = ("id", "actor", "track", "creation_date", "actor")
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "track", "creation_date") fields = ("id", "track", "creation_date")
class SimpleFavoriteSerializer(serializers.Serializer):
id = serializers.IntegerField()
track = serializers.IntegerField()
class AllFavoriteSerializer(serializers.Serializer):
results = SimpleFavoriteSerializer(many=True, source="*")
count = serializers.SerializerMethodField()
def get_count(self, o) -> int:
return len(o)
from rest_framework import routers from funkwhale_api.common import routers
from . import views from . import views
router = routers.SimpleRouter() router = routers.OptionalSlashRouter()
router.register(r"tracks", views.TrackFavoriteViewSet, "tracks") router.register(r"tracks", views.TrackFavoriteViewSet, "tracks")
urlpatterns = router.urls urlpatterns = router.urls
from django.db.models import Prefetch
from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Prefetch from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers from . import filters, models, serializers
...@@ -19,29 +21,46 @@ class TrackFavoriteViewSet( ...@@ -19,29 +21,46 @@ class TrackFavoriteViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
filterset_class = filters.TrackFavoriteFilter filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user") queryset = models.TrackFavorite.objects.all().select_related(
"actor__attachment_icon"
)
permission_classes = [ permission_classes = [
permissions.ConditionalAuthentication, oauth_permissions.ScopePermission,
permissions.OwnerPermission, permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
] ]
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]: if self.request.method.lower() in ["head", "get", "options"]:
return serializers.UserTrackFavoriteSerializer return serializers.UserTrackFavoriteSerializer
return serializers.UserTrackFavoriteWriteSerializer return serializers.UserTrackFavoriteWriteSerializer
@extend_schema(operation_id="favorite_track")
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer) instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance) serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
plugins.trigger_hook(
plugins.FAVORITE_CREATED,
track_favorite=serializer.instance,
confs=plugins.get_confs(self.request.user),
)
record.send(instance) record.send(instance)
routes.outbox.dispatch(
{"type": "Like", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
) )
...@@ -49,29 +68,56 @@ class TrackFavoriteViewSet( ...@@ -49,29 +68,56 @@ class TrackFavoriteViewSet(
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(
self.request.user, "privacy_level", "actor__user"
)
) )
tracks = Track.objects.with_playable_uploads( tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist") )
.prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
)
.select_related(
"attributed_to",
"album__attachment_cover",
)
)
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
return queryset return queryset
def perform_create(self, serializer): def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data["track"]) track = Track.objects.get(pk=serializer.data["track"])
favorite = models.TrackFavorite.add(track=track, user=self.request.user) favorite = models.TrackFavorite.add(track=track, actor=self.request.user.actor)
return favorite return favorite
@extend_schema(operation_id="unfavorite_track")
@action(methods=["delete", "post"], detail=False) @action(methods=["delete", "post"], detail=False)
def remove(self, request, *args, **kwargs): def remove(self, request, *args, **kwargs):
try: try:
pk = int(request.data["track"]) pk = int(request.data["track"])
favorite = request.user.track_favorites.get(track__pk=pk) favorite = request.user.actor.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist): except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400) return Response({}, status=400)
routes.outbox.dispatch(
{"type": "Dislike", "object": {"type": "Track"}},
context={"favorite": favorite},
)
favorite.delete() favorite.delete()
plugins.trigger_hook(
plugins.FAVORITE_DELETED,
track_favorite=favorite,
confs=plugins.get_confs(self.request.user),
)
return Response([], status=status.HTTP_204_NO_CONTENT) return Response([], status=status.HTTP_204_NO_CONTENT)
@extend_schema(
responses=serializers.AllFavoriteSerializer(),
operation_id="get_all_favorite_tracks",
)
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs): def all(self, request, *args, **kwargs):
""" """
...@@ -80,10 +126,11 @@ class TrackFavoriteViewSet( ...@@ -80,10 +126,11 @@ class TrackFavoriteViewSet(
favorites status in the UI favorites status in the UI
""" """
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=200) return Response({"results": [], "count": 0}, status=401)
favorites = list( favorites = request.user.actor.track_favorites.values("id", "track").order_by(
request.user.track_favorites.values("id", "track").order_by("id") "id"
) )
payload = {"results": favorites, "count": len(favorites)} payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200) return Response(payload, status=200)
import uuid
import logging import logging
import urllib.parse
import uuid
from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.db import transaction, IntegrityError from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Q from django.db.models import Q
from funkwhale_api.common import channels from funkwhale_api.common import channels
...@@ -117,46 +118,72 @@ def should_reject(fid, actor_id=None, payload={}): ...@@ -117,46 +118,72 @@ def should_reject(fid, actor_id=None, payload={}):
@transaction.atomic @transaction.atomic
def receive(activity, on_behalf_of): def receive(activity, on_behalf_of, inbox_actor=None):
from . import models """
from . import serializers Receive an activity, find his recipients and save it to the database before dispatching it
from . import tasks """
from funkwhale_api.moderation import mrf
from . import models, serializers, tasks
from .routes import inbox
logger.debug(
"[federation] Received activity from %s : %s", on_behalf_of.fid, activity
)
# we ensure the activity has the bare minimum structure before storing # we ensure the activity has the bare minimum structure before storing
# it in our database # it in our database
serializer = serializers.BaseActivitySerializer( serializer = serializers.BaseActivitySerializer(
data=activity, context={"actor": on_behalf_of, "local_recipients": True} data=activity,
context={
"actor": on_behalf_of,
"local_recipients": True,
"recipients": [inbox_actor] if inbox_actor else [],
},
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
if should_reject(
fid=serializer.validated_data.get("id"), payload, updated = mrf.inbox.apply(activity, sender_id=on_behalf_of.fid)
actor_id=serializer.validated_data["actor"].fid, if not payload:
payload=activity,
):
logger.info( logger.info(
"[federation] Discarding activity due to instance policies %s", "[federation] Discarding activity due to mrf %s",
serializer.validated_data.get("id"), serializer.validated_data.get("id"),
) )
return return
if not inbox.get_matching_handlers(payload):
# discard unhandlable activity
logger.debug(
"[federation] No matching route found for activity, discarding: %s", payload
)
return
try: try:
copy = serializer.save() copy = serializer.save(payload=payload, type=payload["type"])
except IntegrityError: except IntegrityError:
logger.warning( logger.warning(
"[federation] Discarding already elivered activity %s", "[federation] Discarding already delivered activity %s",
serializer.validated_data.get("id"), serializer.validated_data.get("id"),
) )
return return
local_to_recipients = get_actors_from_audience(activity.get("to", [])) local_to_recipients = get_actors_from_audience(
local_to_recipients = local_to_recipients.exclude(user=None) serializer.validated_data.get("to", [])
)
local_cc_recipients = get_actors_from_audience(activity.get("cc", [])) local_to_recipients = local_to_recipients.local()
local_cc_recipients = local_cc_recipients.exclude(user=None) 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(
serializer.validated_data.get("cc", [])
)
local_cc_recipients = local_cc_recipients.local()
local_cc_recipients = local_cc_recipients.values_list("pk", flat=True)
inbox_items = [] inbox_items = []
for recipients, type in [(local_to_recipients, "to"), (local_cc_recipients, "cc")]: for recipients, type in [(local_to_recipients, "to"), (local_cc_recipients, "cc")]:
for r in recipients:
for r in recipients.values_list("pk", flat=True):
inbox_items.append(models.InboxItem(actor_id=r, type=type, activity=copy)) inbox_items.append(models.InboxItem(actor_id=r, type=type, activity=copy))
models.InboxItem.objects.bulk_create(inbox_items) models.InboxItem.objects.bulk_create(inbox_items)
...@@ -197,9 +224,11 @@ class InboxRouter(Router): ...@@ -197,9 +224,11 @@ class InboxRouter(Router):
call_handlers should be False when are delivering a local activity, because call_handlers should be False when are delivering a local activity, because
we want only want to bind activities to their recipients, not reapply the changes. we want only want to bind activities to their recipients, not reapply the changes.
""" """
from . import api_serializers from . import api_serializers, models
from . import models
logger.debug(
f"[federation] Inbox dispatch payload : {payload} with context : {context}"
)
handlers = self.get_matching_handlers(payload) handlers = self.get_matching_handlers(payload)
for handler in handlers: for handler in handlers:
if call_handlers: if call_handlers:
...@@ -217,8 +246,8 @@ class InboxRouter(Router): ...@@ -217,8 +246,8 @@ class InboxRouter(Router):
for k in r.keys(): for k in r.keys():
if k in ["object", "target", "related_object"]: if k in ["object", "target", "related_object"]:
update_fields += [ update_fields += [
"{}_id".format(k), f"{k}_id",
"{}_content_type".format(k), f"{k}_content_type",
] ]
else: else:
update_fields.append(k) update_fields.append(k)
...@@ -240,7 +269,7 @@ class InboxRouter(Router): ...@@ -240,7 +269,7 @@ class InboxRouter(Router):
user = ii.actor.get_user() user = ii.actor.get_user()
if not user: if not user:
continue continue
group = "user.{}.inbox".format(user.pk) group = f"user.{user.pk}.inbox"
channels.group_send( channels.group_send(
group, group,
{ {
...@@ -270,6 +299,69 @@ def schedule_key_rotation(actor_id, delay): ...@@ -270,6 +299,69 @@ def schedule_key_rotation(actor_id, delay):
tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay) tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay)
def activity_pass_user_privacy_level(context, routing):
TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like", "Create"]
TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"]
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
actor = context.get("actor", False)
type = routing.get("type", False)
object_type = routing.get("object", {}).get("type", None)
if not actor:
logger.warning(
"No actor provided in activity context : \
we cannot follow actor.privacy_level, activity will be sent by default."
)
# We do not consider music metadata has private
if object_type in MUSIC_OBJECT_TYPE:
return True
if type:
if type in TYPE_IGNORE_USER_PRIVACY_LEVEL:
return True
if type in TYPE_FOLLOW_USER_PRIVACY_LEVEL and actor and actor.is_local:
if actor.user.privacy_level in [
"me",
"instance",
]:
return False
return True
return True
def activity_pass_object_privacy_level(context, routing):
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
# we only support playlist federation for now (other objects follow user.privacy_level)
object = context.get("playlist", False)
obj_privacy_level = object.privacy_level if object else None
object_type = routing.get("object", {}).get("type", None)
# We do not consider music metadata has private
if object_type in MUSIC_OBJECT_TYPE:
return True
if routing["type"] == "Delete":
return True
if routing["type"] == "Update" and obj_privacy_level in ["me", "instance"]:
# we send a delete request instead
logger.debug(
"[federation] Object privacy level is me or instance, sending delete instead of update"
)
routing["type"] = "Delete"
return True
if object and obj_privacy_level and obj_privacy_level in ["me", "instance"]:
return False
return True
class OutboxRouter(Router): class OutboxRouter(Router):
@transaction.atomic @transaction.atomic
def dispatch(self, routing, context): def dispatch(self, routing, context):
...@@ -278,8 +370,33 @@ class OutboxRouter(Router): ...@@ -278,8 +370,33 @@ class OutboxRouter(Router):
and may yield data that should be persisted in the Activity model and may yield data that should be persisted in the Activity model
for further delivery. for further delivery.
""" """
from . import models from funkwhale_api.common import preferences
from . import tasks
from . import models, tasks
logger.debug(
f"[federation] Outbox dispatch context : {context} and routing : {routing}"
)
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None
if allow_list_enabled:
allowed_domains = set(
models.Domain.objects.filter(allowed=True).values_list(
"name", flat=True
)
)
if activity_pass_user_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to user privacy_level"
)
return
if activity_pass_object_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to object privacy_level"
)
return
for route, handler in self.routes: for route, handler in self.routes:
if not match_route(route, routing): if not match_route(route, routing):
...@@ -308,11 +425,19 @@ class OutboxRouter(Router): ...@@ -308,11 +425,19 @@ class OutboxRouter(Router):
cc = activity_data["payload"].pop("cc", []) cc = activity_data["payload"].pop("cc", [])
a = models.Activity(**activity_data) a = models.Activity(**activity_data)
a.uuid = uuid.uuid4() a.uuid = uuid.uuid4()
to_inbox_items, to_deliveries, new_to = prepare_deliveries_and_inbox_items( (
to, "to" to_inbox_items,
to_deliveries,
new_to,
) = prepare_deliveries_and_inbox_items(
to, "to", allowed_domains=allowed_domains
) )
cc_inbox_items, cc_deliveries, new_cc = prepare_deliveries_and_inbox_items( (
cc, "cc" cc_inbox_items,
cc_deliveries,
new_cc,
) = prepare_deliveries_and_inbox_items(
cc, "cc", allowed_domains=allowed_domains
) )
if not any( if not any(
[to_inbox_items, to_deliveries, cc_inbox_items, cc_deliveries] [to_inbox_items, to_deliveries, cc_inbox_items, cc_deliveries]
...@@ -356,54 +481,46 @@ class OutboxRouter(Router): ...@@ -356,54 +481,46 @@ class OutboxRouter(Router):
) )
for a in activities: for a in activities:
logger.info(f"[federation] Outbox sending activity : {a.pk}")
funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk) funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk)
return activities return activities
def recursive_getattr(obj, key, permissive=False):
"""
Given a dictionary such as {'user': {'name': 'Bob'}} and
a dotted string such as user.name, returns 'Bob'.
If the value is not present, returns None
"""
v = obj
for k in key.split("."):
try:
v = v.get(k)
except (TypeError, AttributeError):
if not permissive:
raise
return
if v is None:
return
return v
def match_route(route, payload): def match_route(route, payload):
for key, value in route.items(): for key, value in route.items():
payload_value = recursive_getattr(payload, key) payload_value = recursive_getattr(payload, key, permissive=True)
if payload_value != value: if isinstance(value, list):
if payload_value not in value:
return False
elif payload_value != value:
return False return False
return True return True
def prepare_deliveries_and_inbox_items(recipient_list, type): def is_allowed_url(url, allowed_domains):
return (
allowed_domains is None
or urllib.parse.urlparse(url).hostname in allowed_domains
)
def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=None):
""" """
Given a list of recipients ( Given a list of recipients (
either actor instances, public adresses, a dictionnary with a "type" and "target" either actor instances, public addresses, a dictionary with a "type" and "target"
keys for followers collections) keys for followers collections)
returns a list of deliveries, alist of inbox_items and a list returns a list of deliveries, alist of inbox_items and a list
of urls to persist in the activity in place of the initial recipient list. of urls to persist in the activity in place of the initial recipient list.
""" """
from . import models from . import models
if allowed_domains is not None:
allowed_domains = set(allowed_domains)
allowed_domains.add(settings.FEDERATION_HOSTNAME)
local_recipients = set() local_recipients = set()
remote_inbox_urls = set() remote_inbox_urls = set()
urls = [] urls = []
for r in recipient_list: for r in recipient_list:
if isinstance(r, models.Actor): if isinstance(r, models.Actor):
if r.is_local: if r.is_local:
...@@ -426,8 +543,60 @@ def prepare_deliveries_and_inbox_items(recipient_list, type): ...@@ -426,8 +543,60 @@ def prepare_deliveries_and_inbox_items(recipient_list, type):
else: else:
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url) remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_url) urls.append(r["target"].followers_url)
elif isinstance(r, dict) and r["type"] == "actor_inbox":
actor = r["actor"]
urls.append(actor.fid)
if actor.is_local:
local_recipients.add(actor)
else:
remote_inbox_urls.add(actor.inbox_url)
elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors
# when we have at least one follower from this instance
follows = (
models.LibraryFollow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
.union(
models.Follow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
)
)
followed_domains = list(follows.values_list("actor__domain_id", flat=True))
actors = models.Actor.objects.filter(
managed_domains__name__in=followed_domains
)
values = actors.values("shared_inbox_url", "inbox_url", "domain_id")
handled_domains = set()
for v in values:
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
handled_domains.add(v["domain_id"])
if len(handled_domains) >= len(followed_domains):
continue
deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls] # for all remaining domains (probably non-funkwhale instances, with no
# service actors), we also pick the latest known actor per domain and send the message
# there instead
remaining_domains = models.Domain.objects.exclude(name__in=handled_domains)
remaining_domains = remaining_domains.filter(name__in=followed_domains)
actors = models.Actor.objects.filter(domain__in=remaining_domains)
actors = (
actors.order_by("domain_id", "-last_fetch_date")
.distinct("domain_id")
.values("shared_inbox_url", "inbox_url")
)
for v in actors:
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
deliveries = [
models.Delivery(inbox_url=url)
for url in remote_inbox_urls
if is_allowed_url(url, allowed_domains)
]
urls = [url for url in urls if is_allowed_url(url, allowed_domains)]
inbox_items = [ inbox_items = [
models.InboxItem(actor=actor, type=type) for actor in local_recipients models.InboxItem(actor=actor, type=type) for actor in local_recipients
] ]
...@@ -435,13 +604,6 @@ def prepare_deliveries_and_inbox_items(recipient_list, type): ...@@ -435,13 +604,6 @@ def prepare_deliveries_and_inbox_items(recipient_list, type):
return inbox_items, deliveries, urls return inbox_items, deliveries, urls
def join_queries_or(left, right):
if left:
return left | right
else:
return right
def get_actors_from_audience(urls): def get_actors_from_audience(urls):
""" """
Given a list of urls such as [ Given a list of urls such as [
...@@ -463,24 +625,20 @@ def get_actors_from_audience(urls): ...@@ -463,24 +625,20 @@ def get_actors_from_audience(urls):
if url == PUBLIC_ADDRESS: if url == PUBLIC_ADDRESS:
continue continue
queries["actors"].append(url) queries["actors"].append(url)
queries["followed"] = join_queries_or( queries["followed"] = funkwhale_utils.join_queries_or(
queries["followed"], Q(target__followers_url=url) queries["followed"], Q(target__followers_url=url)
) )
final_query = None final_query = None
if queries["actors"]: if queries["actors"]:
final_query = join_queries_or(final_query, Q(fid__in=queries["actors"])) final_query = funkwhale_utils.join_queries_or(
final_query, Q(fid__in=queries["actors"])
)
if queries["followed"]: if queries["followed"]:
actor_follows = models.Follow.objects.filter(queries["followed"], approved=True) actor_follows = models.Follow.objects.filter(queries["followed"], approved=True)
final_query = join_queries_or( final_query = funkwhale_utils.join_queries_or(
final_query, Q(pk__in=actor_follows.values_list("actor", flat=True)) final_query, Q(pk__in=actor_follows.values_list("actor", flat=True))
) )
library_follows = models.LibraryFollow.objects.filter(
queries["followed"], approved=True
)
final_query = join_queries_or(
final_query, Q(pk__in=library_follows.values_list("actor", flat=True))
)
if not final_query: if not final_query:
return models.Actor.objects.none() return models.Actor.objects.none()
return models.Actor.objects.filter(final_query) return models.Actor.objects.filter(final_query)
...@@ -13,17 +13,16 @@ logger = logging.getLogger(__name__) ...@@ -13,17 +13,16 @@ logger = logging.getLogger(__name__)
def get_actor_data(actor_url): def get_actor_data(actor_url):
logger.debug("Fetching actor %s", actor_url)
response = session.get_session().get( response = session.get_session().get(
actor_url, actor_url,
timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={"Accept": "application/activity+json"}, headers={"Accept": "application/activity+json"},
) )
response.raise_for_status() response.raise_for_status()
try: try:
return response.json() return response.json()
except Exception: except Exception:
raise ValueError("Invalid actor payload: {}".format(response.text)) raise ValueError(f"Invalid actor payload: {response.text}")
def get_actor(fid, skip_cache=False): def get_actor(fid, skip_cache=False):
...@@ -45,21 +44,32 @@ def get_actor(fid, skip_cache=False): ...@@ -45,21 +44,32 @@ def get_actor(fid, skip_cache=False):
return serializer.save(last_fetch_date=timezone.now()) return serializer.save(last_fetch_date=timezone.now())
def get_service_actor(): _CACHE = {}
def get_service_actor(cache=True):
if cache and "service_actor" in _CACHE:
return _CACHE["service_actor"]
name, domain = ( name, domain = (
settings.FEDERATION_SERVICE_ACTOR_USERNAME, settings.FEDERATION_SERVICE_ACTOR_USERNAME,
settings.FEDERATION_HOSTNAME, settings.FEDERATION_HOSTNAME,
) )
try: try:
return models.Actor.objects.select_related().get( actor = models.Actor.objects.select_related().get(
preferred_username=name, domain__name=domain preferred_username=name, domain__name=domain
) )
except models.Actor.DoesNotExist: except models.Actor.DoesNotExist:
pass pass
else:
_CACHE["service_actor"] = actor
return actor
args = users_models.get_actor_data(name) args = users_models.get_actor_data(name)
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
args["private_key"] = private.decode("utf-8") args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8") args["public_key"] = public.decode("utf-8")
args["type"] = "Service" args["type"] = "Service"
return models.Actor.objects.create(**args) actor = models.Actor.objects.create(**args)
_CACHE["service_actor"] = actor
return actor
from funkwhale_api.common import admin from funkwhale_api.common import admin
from . import models from . import models, tasks
from . import tasks
def redeliver_deliveries(modeladmin, request, queryset): def redeliver_deliveries(modeladmin, request, queryset):
...@@ -26,15 +25,24 @@ redeliver_activities.short_description = "Redeliver" ...@@ -26,15 +25,24 @@ redeliver_activities.short_description = "Redeliver"
@admin.register(models.Domain) @admin.register(models.Domain)
class DomainAdmin(admin.ModelAdmin): class DomainAdmin(admin.ModelAdmin):
list_display = ["name", "creation_date"] list_display = ["name", "allowed", "creation_date"]
list_filter = ["allowed"]
search_fields = ["name"] search_fields = ["name"]
@admin.register(models.Fetch)
class FetchAdmin(admin.ModelAdmin):
list_display = ["url", "actor", "status", "creation_date", "fetch_date", "detail"]
search_fields = ["url", "actor__username"]
list_filter = ["status"]
list_select_related = True
@admin.register(models.Activity) @admin.register(models.Activity)
class ActivityAdmin(admin.ModelAdmin): class ActivityAdmin(admin.ModelAdmin):
list_display = ["type", "fid", "url", "actor", "creation_date"] list_display = ["uuid", "type", "fid", "url", "actor", "creation_date"]
search_fields = ["payload", "fid", "url", "actor__domain"] search_fields = ["payload", "fid", "url", "actor__domain__name"]
list_filter = ["type", "actor__domain"] list_filter = ["type", "actor__domain__name"]
actions = [redeliver_activities] actions = [redeliver_activities]
list_select_related = True list_select_related = True
...@@ -49,7 +57,7 @@ class ActorAdmin(admin.ModelAdmin): ...@@ -49,7 +57,7 @@ class ActorAdmin(admin.ModelAdmin):
"creation_date", "creation_date",
"last_fetch_date", "last_fetch_date",
] ]
search_fields = ["fid", "domain", "preferred_username"] search_fields = ["fid", "domain__name", "preferred_username"]
list_filter = ["type"] list_filter = ["type"]
......
import datetime
from urllib.parse import urlparse
from django.conf import settings
from django.core import validators
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
from funkwhale_api.audio import serializers as audio_serializers
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.users import serializers as users_serializers
from . import filters from . import filters, models
from . import models
from . import serializers as federation_serializers from . import serializers as federation_serializers
...@@ -27,11 +39,16 @@ class LibraryScanSerializer(serializers.ModelSerializer): ...@@ -27,11 +39,16 @@ class LibraryScanSerializer(serializers.ModelSerializer):
] ]
class DomainSerializer(serializers.Serializer):
name = serializers.CharField()
class LibrarySerializer(serializers.ModelSerializer): class LibrarySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer() actor = federation_serializers.APIActorSerializer()
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
latest_scan = serializers.SerializerMethodField() latest_scan = LibraryScanSerializer(required=False, allow_null=True)
follow = serializers.SerializerMethodField() # The follow field is likely broken, so I removed the test
follow = NestedLibraryFollowSerializer(required=False, allow_null=True)
class Meta: class Meta:
model = music_models.Library model = music_models.Library
...@@ -40,7 +57,6 @@ class LibrarySerializer(serializers.ModelSerializer): ...@@ -40,7 +57,6 @@ class LibrarySerializer(serializers.ModelSerializer):
"uuid", "uuid",
"actor", "actor",
"name", "name",
"description",
"creation_date", "creation_date",
"uploads_count", "uploads_count",
"privacy_level", "privacy_level",
...@@ -48,20 +64,16 @@ class LibrarySerializer(serializers.ModelSerializer): ...@@ -48,20 +64,16 @@ class LibrarySerializer(serializers.ModelSerializer):
"latest_scan", "latest_scan",
] ]
def get_uploads_count(self, o): def get_uploads_count(self, o) -> int:
return max(getattr(o, "_uploads_count", 0), o.uploads_count) return max(getattr(o, "_uploads_count", 0), o.uploads_count)
@extend_schema_field(NestedLibraryFollowSerializer)
def get_follow(self, o): def get_follow(self, o):
try: try:
return NestedLibraryFollowSerializer(o._follows[0]).data return NestedLibraryFollowSerializer(o._follows[0]).data
except (AttributeError, IndexError): except (AttributeError, IndexError):
return None return None
def get_latest_scan(self, o):
scan = o.scans.order_by("-creation_date").first()
if scan:
return LibraryScanSerializer(scan).data
class LibraryFollowSerializer(serializers.ModelSerializer): class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
...@@ -81,24 +93,56 @@ class LibraryFollowSerializer(serializers.ModelSerializer): ...@@ -81,24 +93,56 @@ class LibraryFollowSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("You are already following this library") raise serializers.ValidationError("You are already following this library")
return v return v
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
class FollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField(
"fid", federation_serializers.APIActorSerializer(), required=True
)
actor = serializers.SerializerMethodField()
class Meta:
model = models.Follow
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
request_actor = self.context["actor"]
if v == request_actor:
raise serializers.ValidationError("You cannot follow yourself")
if v.received_follows.filter(actor=request_actor).exists():
raise serializers.ValidationError("You are already following this user")
return v
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, o): def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj): def serialize_generic_relation(activity, obj):
data = {"uuid": obj.uuid, "type": obj._meta.label} data = {"type": obj._meta.label}
if data["type"] == "federation.Actor":
data["full_username"] = obj.full_username
else:
data["uuid"] = obj.uuid
if data["type"] == "music.Library": if data["type"] == "music.Library":
data["name"] = obj.name data["name"] = obj.name
if data["type"] == "federation.LibraryFollow": if (
data["type"] == "federation.LibraryFollow"
or data["type"] == "federation.Follow"
):
data["approved"] = obj.approved data["approved"] = obj.approved
return data return data
class ActivitySerializer(serializers.ModelSerializer): class ActivitySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer() actor = federation_serializers.APIActorSerializer()
object = serializers.SerializerMethodField() object = serializers.SerializerMethodField(allow_null=True)
target = serializers.SerializerMethodField() target = serializers.SerializerMethodField(allow_null=True)
related_object = serializers.SerializerMethodField() related_object = serializers.SerializerMethodField()
class Meta: class Meta:
...@@ -116,14 +160,17 @@ class ActivitySerializer(serializers.ModelSerializer): ...@@ -116,14 +160,17 @@ class ActivitySerializer(serializers.ModelSerializer):
"type", "type",
] ]
@extend_schema_field(OpenApiTypes.OBJECT, None)
def get_object(self, o): def get_object(self, o):
if o.object: if o.object:
return serialize_generic_relation(o, o.object) return serialize_generic_relation(o, o.object)
@extend_schema_field(OpenApiTypes.OBJECT)
def get_related_object(self, o): def get_related_object(self, o):
if o.related_object: if o.related_object:
return serialize_generic_relation(o, o.related_object) return serialize_generic_relation(o, o.related_object)
@extend_schema_field(OpenApiTypes.OBJECT)
def get_target(self, o): def get_target(self, o):
if o.target: if o.target:
return serialize_generic_relation(o, o.target) return serialize_generic_relation(o, o.target)
...@@ -144,3 +191,166 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer): ...@@ -144,3 +191,166 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer):
def handle_read(self, objects): def handle_read(self, objects):
return objects.update(is_read=True) return objects.update(is_read=True)
OBJECT_SERIALIZER_MAPPING = {
music_models.Artist: federation_serializers.ArtistSerializer,
music_models.Album: federation_serializers.AlbumSerializer,
music_models.Track: federation_serializers.TrackSerializer,
models.Actor: federation_serializers.APIActorSerializer,
audio_models.Channel: audio_serializers.ChannelSerializer,
playlists_models.Playlist: federation_serializers.PlaylistSerializer,
}
def convert_url_to_webfinger(url):
parsed_url = urlparse(url)
domain = parsed_url.netloc # e.g., "node1.funkwhale.test"
path_parts = parsed_url.path.strip("/").split("/")
# Ensure the path is in the expected format
if len(path_parts) > 0 and path_parts[0].startswith("@"):
username = path_parts[0][1:] # Remove the '@'
return f"{username}@{domain}"
return None
class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True)
object_uri = serializers.CharField(required=True, write_only=True)
object = serializers.SerializerMethodField(read_only=True)
type = serializers.SerializerMethodField(read_only=True)
force = serializers.BooleanField(default=False, required=False, write_only=True)
class Meta:
model = models.Fetch
fields = [
"id",
"url",
"actor",
"status",
"detail",
"creation_date",
"fetch_date",
"object_uri",
"force",
"type",
"object",
]
read_only_fields = [
"id",
"url",
"actor",
"status",
"detail",
"creation_date",
"fetch_date",
"type",
"object",
]
def get_type(self, fetch):
obj = fetch.object
if obj is None:
return None
# Return the type as a string
if isinstance(obj, music_models.Artist):
return "artist"
elif isinstance(obj, music_models.Album):
return "album"
elif isinstance(obj, music_models.Track):
return "track"
elif isinstance(obj, models.Actor):
return "account"
elif isinstance(obj, audio_models.Channel):
return "channel"
elif isinstance(obj, playlists_models.Playlist):
return "playlist"
else:
return None
def validate_object_uri(self, value):
if value.startswith("https://"):
converted = convert_url_to_webfinger(value)
if converted:
value = converted
if value.startswith("@"):
value = value.lstrip("@")
validator = validators.EmailValidator()
try:
validator(value)
except validators.ValidationError:
return value
return f"webfinger://{value}"
@extend_schema_field(
{
"oneOf": [
{"$ref": "#/components/schemas/Artist"},
{"$ref": "#/components/schemas/Album"},
{"$ref": "#/components/schemas/Track"},
{"$ref": "#/components/schemas/APIActor"},
{"$ref": "#/components/schemas/Channel"},
{"$ref": "#/components/schemas/Playlist"},
]
}
)
def get_object(self, fetch):
obj = fetch.object
if obj is None:
return None
serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj))
if serializer_class:
return serializer_class(obj).data
return None
def create(self, validated_data):
check_duplicates = not validated_data.get("force", False)
if check_duplicates:
# first we check for duplicates
duplicate = (
validated_data["actor"]
.fetches.filter(
status="finished",
url=validated_data["object_uri"],
creation_date__gte=timezone.now()
- datetime.timedelta(
seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY
),
)
.order_by("-creation_date")
.first()
)
if duplicate:
return duplicate
fetch = models.Fetch.objects.create(
actor=validated_data["actor"], url=validated_data["object_uri"]
)
return fetch
class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField()
url = serializers.URLField()
domain = serializers.CharField(source="domain_id")
creation_date = serializers.DateTimeField()
last_fetch_date = serializers.DateTimeField()
name = serializers.CharField()
preferred_username = serializers.CharField()
full_username = serializers.CharField()
type = serializers.CharField()
is_local = serializers.BooleanField()
is_channel = serializers.SerializerMethodField()
manually_approves_followers = serializers.BooleanField()
user = users_serializers.UserBasicSerializer()
summary = common_serializers.ContentSerializer(source="summary_obj")
icon = common_serializers.AttachmentSerializer(source="attachment_icon")
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_channel(self, o):
try:
return bool(o.channel)
except ObjectDoesNotExist:
return False
from rest_framework import routers from funkwhale_api.common import routers
from . import api_views from . import api_views
router = routers.SimpleRouter() router = routers.OptionalSlashRouter()
router.register(r"fetches", api_views.FetchViewSet, "fetches")
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"follows/user", api_views.UserFollowViewSet, "user-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries") router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains")
router.register(r"actors", api_views.ActorViewSet, "actors")
urlpatterns = router.urls urlpatterns = router.urls
import requests.exceptions import requests.exceptions
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Count from django.db.models import Count, Q
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import decorators from rest_framework import decorators, mixins, permissions, response, viewsets
from rest_framework import mixins from rest_framework.exceptions import NotFound as RestNotFound
from rest_framework import permissions
from rest_framework import response
from rest_framework import viewsets
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity from . import (
from . import api_serializers activity,
from . import exceptions api_serializers,
from . import filters exceptions,
from . import models filters,
from . import routes models,
from . import serializers routes,
from . import utils serializers,
tasks,
utils,
)
@transaction.atomic @transaction.atomic
...@@ -27,8 +33,14 @@ def update_follow(follow, approved): ...@@ -27,8 +33,14 @@ def update_follow(follow, approved):
follow.save(update_fields=["approved"]) follow.save(update_fields=["approved"])
if approved: if approved:
routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow}) routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow})
else:
routes.outbox.dispatch({"type": "Reject"}, context={"follow": follow})
@extend_schema_view(
list=extend_schema(operation_id="get_federation_library_follows"),
create=extend_schema(operation_id="create_federation_library_follow"),
)
class LibraryFollowViewSet( class LibraryFollowViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
...@@ -43,13 +55,22 @@ class LibraryFollowViewSet( ...@@ -43,13 +55,22 @@ class LibraryFollowViewSet(
.select_related("actor", "target__actor") .select_related("actor", "target__actor")
) )
serializer_class = api_serializers.LibraryFollowSerializer serializer_class = api_serializers.LibraryFollowSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
filterset_class = filters.LibraryFollowFilter filterset_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",) ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_library_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_library_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
return qs.filter(actor=self.request.user.actor) return qs.filter(actor=self.request.user.actor).exclude(approved=False)
def perform_create(self, serializer): def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor) follow = serializer.save(actor=self.request.user.actor)
...@@ -67,6 +88,10 @@ class LibraryFollowViewSet( ...@@ -67,6 +88,10 @@ class LibraryFollowViewSet(
context["actor"] = self.request.user.actor context["actor"] = self.request.user.actor
return context return context
@extend_schema(
operation_id="accept_federation_library_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True) @decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs): def accept(self, request, *args, **kwargs):
try: try:
...@@ -78,6 +103,7 @@ class LibraryFollowViewSet( ...@@ -78,6 +103,7 @@ class LibraryFollowViewSet(
update_follow(follow, approved=True) update_follow(follow, approved=True)
return response.Response(status=204) return response.Response(status=204)
@extend_schema(operation_id="reject_federation_library_follow")
@decorators.action(methods=["post"], detail=True) @decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs): def reject(self, request, *args, **kwargs):
try: try:
...@@ -90,6 +116,27 @@ class LibraryFollowViewSet( ...@@ -90,6 +116,27 @@ class LibraryFollowViewSet(
update_follow(follow, approved=False) update_follow(follow, approved=False)
return response.Response(status=204) return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__uuid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)
class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid" lookup_field = "uuid"
...@@ -100,7 +147,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -100,7 +147,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
.annotate(_uploads_count=Count("uploads")) .annotate(_uploads_count=Count("uploads"))
) )
serializer_class = api_serializers.LibrarySerializer serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
...@@ -132,6 +180,7 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -132,6 +180,7 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
try: try:
library = utils.retrieve_ap_object( library = utils.retrieve_ap_object(
fid, fid,
actor=request.user.actor,
queryset=self.queryset, queryset=self.queryset,
serializer_class=serializers.LibrarySerializer, serializer_class=serializers.LibrarySerializer,
) )
...@@ -142,12 +191,12 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -142,12 +191,12 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
) )
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
return response.Response( return response.Response(
{"detail": "Error while fetching the library: {}".format(str(e))}, {"detail": f"Error while fetching the library: {str(e)}"},
status=400, status=400,
) )
except serializers.serializers.ValidationError as e: except serializers.serializers.ValidationError as e:
return response.Response( return response.Response(
{"detail": "Invalid data in remote library: {}".format(str(e))}, {"detail": f"Invalid data in remote library: {str(e)}"},
status=400, status=400,
) )
serializer = self.serializer_class(library) serializer = self.serializer_class(library)
...@@ -160,7 +209,6 @@ class InboxItemViewSet( ...@@ -160,7 +209,6 @@ class InboxItemViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
queryset = ( queryset = (
models.InboxItem.objects.select_related("activity__actor") models.InboxItem.objects.select_related("activity__actor")
.prefetch_related("activity__object", "activity__target") .prefetch_related("activity__object", "activity__target")
...@@ -168,7 +216,8 @@ class InboxItemViewSet( ...@@ -168,7 +216,8 @@ class InboxItemViewSet(
.order_by("-activity__creation_date") .order_by("-activity__creation_date")
) )
serializer_class = api_serializers.InboxItemSerializer serializer_class = api_serializers.InboxItemSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [oauth_permissions.ScopePermission]
required_scope = "notifications"
filterset_class = filters.InboxItemFilter filterset_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",) ordering_fields = ("activity__creation_date",)
...@@ -185,3 +234,189 @@ class InboxItemViewSet( ...@@ -185,3 +234,189 @@ class InboxItemViewSet(
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result = serializer.save() result = serializer.save()
return response.Response(result, status=200) return response.Response(result, status=200)
class FetchViewSet(
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
queryset = models.Fetch.objects.select_related("actor")
serializer_class = api_serializers.FetchSerializer
permission_classes = [permissions.IsAuthenticated]
throttling_scopes = {"create": {"authenticated": "fetch"}}
def get_queryset(self):
return super().get_queryset().filter(actor=self.request.user.actor)
def perform_create(self, serializer):
fetch = serializer.save(actor=self.request.user.actor)
if fetch.status == "finished":
# a duplicate was returned, no need to fetch again
return
if settings.FEDERATION_SYNCHRONOUS_FETCH:
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
else:
common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk)
class DomainViewSet(
mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
):
queryset = models.Domain.objects.order_by("name").external()
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.DomainSerializer
ordering_fields = ("creation_date", "name")
max_page_size = 100
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
instance_policy__is_active=True, instance_policy__block_all=True
)
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(allowed=True)
return qs
class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = models.Actor.objects.select_related(
"user", "channel", "summary_obj", "attachment_icon"
)
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.FullActorSerializer
lookup_field = "full_username"
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
def get_object(self):
queryset = self.get_queryset()
username, domain = self.kwargs["full_username"].split("@", 1)
try:
return queryset.get(preferred_username=username, domain_id=domain)
except models.Actor.DoesNotExist:
raise RestNotFound(
detail=f"Actor {username}@{domain} not found",
)
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
domain__instance_policy__is_active=True,
domain__instance_policy__block_all=True,
)
if preferences.get("moderation__allow_list_enabled"):
query = Q(domain_id=settings.FUNKWHALE_HOSTNAME) | Q(domain__allowed=True)
qs = qs.filter(query)
return qs
libraries = decorators.action(
methods=["get"],
detail=True,
serializer_class=music_serializers.LibraryForOwnerSerializer,
)(
music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)
@extend_schema_view(
list=extend_schema(operation_id="get_federation_received_follows"),
create=extend_schema(operation_id="create_federation_user_follow"),
)
class UserFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.Follow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target")
.filter(actor__type="Person")
)
serializer_class = api_serializers.FollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_user_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_user_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(
Q(target=self.request.user.actor) | Q(actor=self.request.user.actor)
).exclude(approved=False)
def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor)
routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance}
)
instance.delete()
def get_serializer_context(self):
context = super().get_serializer_context()
context["actor"] = self.request.user.actor
return context
@extend_schema(
operation_id="accept_federation_user_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_user_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__fid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "actor": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)
import cryptography
import logging
import datetime import datetime
import logging
import urllib.parse
import cryptography
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.utils import timezone from django.utils import timezone
from rest_framework import authentication
from rest_framework import exceptions as rest_exceptions
from rest_framework import authentication, exceptions as rest_exceptions from funkwhale_api.common import preferences
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_models
from . import actors, exceptions, keys, signing, tasks, utils
from . import actors, exceptions, keys, models, signing, tasks, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -37,13 +40,28 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -37,13 +40,28 @@ class SignatureAuthentication(authentication.BaseAuthentication):
if policies.exists(): if policies.exists():
raise exceptions.BlockedActorOrDomain() raise exceptions.BlockedActorOrDomain()
if request.method.lower() == "get" and preferences.get(
"moderation__allow_list_enabled"
):
# Only GET requests because POST requests with messages will be handled through
# MRF
domain = urllib.parse.urlparse(actor_url).hostname
allowed = models.Domain.objects.filter(name=domain, allowed=True).exists()
if not allowed:
logger.debug("Actor domain %s is not on allow-list", domain)
raise exceptions.BlockedActorOrDomain()
try: try:
actor = actors.get_actor(actor_url) actor = actors.get_actor(actor_url)
except Exception as e: except Exception as e:
logger.info( logger.info(
"Discarding HTTP request from blocked actor/domain %s", actor_url "Discarding HTTP request from actor/domain %s, %s",
actor_url,
str(e),
)
raise rest_exceptions.AuthenticationFailed(
"Cannot fetch remote actor to authenticate signature"
) )
raise rest_exceptions.AuthenticationFailed(str(e))
if not actor.public_key: if not actor.public_key:
raise rest_exceptions.AuthenticationFailed("No public key found") raise rest_exceptions.AuthenticationFailed("No public key found")
...@@ -63,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -63,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
fetch_delay = 24 * 3600 fetch_delay = 24 * 3600
now = timezone.now() now = timezone.now()
last_fetch = actor.domain.nodeinfo_fetch_date last_fetch = actor.domain.nodeinfo_fetch_date
if not actor.domain.is_local:
if not last_fetch or ( if not last_fetch or (
last_fetch < (now - datetime.timedelta(seconds=fetch_delay)) last_fetch < (now - datetime.timedelta(seconds=fetch_delay))
): ):
......
from . import schema_org
CONTEXTS = [ CONTEXTS = [
{ {
"shortId": "LDP", "shortId": "LDP",
...@@ -214,9 +216,16 @@ CONTEXTS = [ ...@@ -214,9 +216,16 @@ CONTEXTS = [
"shares": {"@id": "as:shares", "@type": "@id"}, "shares": {"@id": "as:shares", "@type": "@id"},
# Added manually # Added manually
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
} }
}, },
}, },
{
"shortId": "SC",
"contextUrl": None,
"documentUrl": "http://schema.org",
"document": {"@context": schema_org.CONTEXT},
},
{ {
"shortId": "SEC", "shortId": "SEC",
"contextUrl": None, "contextUrl": None,
...@@ -279,11 +288,15 @@ CONTEXTS = [ ...@@ -279,11 +288,15 @@ CONTEXTS = [
"type": "@type", "type": "@type",
"as": "https://www.w3.org/ns/activitystreams#", "as": "https://www.w3.org/ns/activitystreams#",
"fw": "https://funkwhale.audio/ns#", "fw": "https://funkwhale.audio/ns#",
"schema": "http://schema.org#",
"xsd": "http://www.w3.org/2001/XMLSchema#", "xsd": "http://www.w3.org/2001/XMLSchema#",
"Album": "fw:Album", "Album": "fw:Album",
"Track": "fw:Track", "Track": "fw:Track",
"Artist": "fw:Artist", "Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library", "Library": "fw:Library",
"Playlist": "fw:Playlist",
"PlaylistTrack": "fw:PlaylistTrack",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"}, "bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"}, "size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"}, "position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
...@@ -292,11 +305,57 @@ CONTEXTS = [ ...@@ -292,11 +305,57 @@ CONTEXTS = [
"track": {"@id": "fw:track", "@type": "@id"}, "track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"}, "cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"}, "album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"}, "artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
"artist_credit": {
"@id": "fw:artist_credit",
"@type": "@id",
"@container": "@list",
},
"joinphrase": {"@id": "fw:joinphrase", "@type": "xsd:string"},
"credit": {"@id": "fw:credit", "@type": "xsd:string"},
"index": {"@id": "fw:index", "@type": "xsd:nonNegativeInteger"},
"released": {"@id": "fw:released", "@type": "xsd:date"}, "released": {"@id": "fw:released", "@type": "xsd:date"},
"musicbrainzId": "fw:musicbrainzId", "musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"}, "license": {"@id": "fw:license", "@type": "@id"},
"copyright": "fw:copyright", "copyright": "fw:copyright",
"category": "schema:category",
"language": "schema:inLanguage",
"playlist": {"@id": "fw:playlist", "@type": "@id"},
}
},
},
{
"shortId": "LITEPUB",
"contextUrl": None,
"documentUrl": "http://litepub.social/ns",
# from https://git.pleroma.social/pleroma/pleroma/-/blob/release/2.2.3/priv/static/schemas/litepub-0.1.jsonld
"document": {
"@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",
"capabilities": "litepub:capabilities",
"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",
"ChatMessage": "litepub:ChatMessage",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
} }
}, },
}, },
...@@ -316,14 +375,14 @@ class NS: ...@@ -316,14 +375,14 @@ class NS:
def __getattr__(self, key): def __getattr__(self, key):
if key not in self.conf["document"]["@context"]: if key not in self.conf["document"]["@context"]:
raise AttributeError( raise AttributeError(
"{} is not a valid property of context {}".format(key, self.baseUrl) f"{key} is not a valid property of context {self.baseUrl}"
) )
return self.baseUrl + key return self.baseUrl + key
class NoopContext: class NoopContext:
def __getattr__(self, key): def __getattr__(self, key):
return "_:{}".format(key) return f"_:{key}"
NOOP = NoopContext() NOOP = NoopContext()
...@@ -331,3 +390,5 @@ AS = NS(CONTEXTS_BY_ID["AS"]) ...@@ -331,3 +390,5 @@ AS = NS(CONTEXTS_BY_ID["AS"])
LDP = NS(CONTEXTS_BY_ID["LDP"]) LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"]) SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"]) FW = NS(CONTEXTS_BY_ID["FW"])
SC = NS(CONTEXTS_BY_ID["SC"])
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])
from django.db import transaction
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import decorators, permissions, response, status
from funkwhale_api.common import utils as common_utils
from . import api_serializers, filters, models, tasks, utils
def fetches_route():
@transaction.atomic
def fetches(self, request, *args, **kwargs):
obj = self.get_object()
if request.method == "GET":
queryset = models.Fetch.objects.get_for_object(obj).select_related("actor")
queryset = queryset.order_by("-creation_date")
filterset = filters.FetchFilter(request.GET, queryset=queryset)
page = self.paginate_queryset(filterset.qs)
if page is not None:
serializer = api_serializers.FetchSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = api_serializers.FetchSerializer(queryset, many=True)
return response.Response(serializer.data)
if request.method == "POST":
if utils.is_local(obj.fid):
return response.Response(
{"detail": "Cannot fetch a local object"}, status=400
)
fetch = models.Fetch.objects.create(
url=obj.fid, actor=request.user.actor, object=obj
)
common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk)
serializer = api_serializers.FetchSerializer(fetch)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return extend_schema(methods=["post"], responses=api_serializers.FetchSerializer())(
extend_schema(
methods=["get"],
responses=api_serializers.FetchSerializer(many=True),
parameters=[OpenApiParameter("id", location="query", exclude=True)],
)(
decorators.action(
methods=["get", "post"],
detail=True,
permission_classes=[permissions.IsAuthenticated],
)(fetches)
)
)
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
federation = types.Section("federation") federation = types.Section("federation")
...@@ -14,7 +12,7 @@ class MusicCacheDuration(types.IntPreference): ...@@ -14,7 +12,7 @@ class MusicCacheDuration(types.IntPreference):
default = 60 * 24 * 2 default = 60 * 24 * 2
verbose_name = "Music cache duration" verbose_name = "Music cache duration"
help_text = ( help_text = (
"How much minutes do you want to keep a copy of federated tracks" "How many minutes do you want to keep a copy of federated tracks "
"locally? Federated files that were not listened in this interval " "locally? Federated files that were not listened in this interval "
"will be erased and refetched from the remote on the next listening." "will be erased and refetched from the remote on the next listening."
) )
...@@ -22,10 +20,10 @@ class MusicCacheDuration(types.IntPreference): ...@@ -22,10 +20,10 @@ class MusicCacheDuration(types.IntPreference):
@global_preferences_registry.register @global_preferences_registry.register
class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): class Enabled(types.BooleanPreference):
section = federation section = federation
name = "enabled" name = "enabled"
setting = "FEDERATION_ENABLED" default = True
verbose_name = "Federation enabled" verbose_name = "Federation enabled"
help_text = ( help_text = (
"Use this setting to enable or disable federation logic and API" " globally." "Use this setting to enable or disable federation logic and API" " globally."
...@@ -33,35 +31,33 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): ...@@ -33,35 +31,33 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
@global_preferences_registry.register @global_preferences_registry.register
class CollectionPageSize(preferences.DefaultFromSettingMixin, types.IntPreference): class CollectionPageSize(types.IntPreference):
section = federation section = federation
name = "collection_page_size" name = "collection_page_size"
setting = "FEDERATION_COLLECTION_PAGE_SIZE" default = 50
verbose_name = "Federation collection page size" verbose_name = "Federation collection page size"
help_text = "How much items to display in ActivityPub collections." help_text = "How many items to display in ActivityPub collections."
field_kwargs = {"required": False} field_kwargs = {"required": False}
@global_preferences_registry.register @global_preferences_registry.register
class ActorFetchDelay(preferences.DefaultFromSettingMixin, types.IntPreference): class ActorFetchDelay(types.IntPreference):
section = federation section = federation
name = "actor_fetch_delay" name = "actor_fetch_delay"
setting = "FEDERATION_ACTOR_FETCH_DELAY" default = 60 * 12
verbose_name = "Federation actor fetch delay" verbose_name = "Federation actor fetch delay"
help_text = ( help_text = (
"How much minutes to wait before refetching actors on " "How many minutes to wait before refetching actors on "
"request authentication." "request authentication."
) )
field_kwargs = {"required": False} field_kwargs = {"required": False}
@global_preferences_registry.register @global_preferences_registry.register
class MusicNeedsApproval(preferences.DefaultFromSettingMixin, types.BooleanPreference): class PublicIndex(types.BooleanPreference):
show_in_api = True
section = federation section = federation
name = "music_needs_approval" name = "public_index"
setting = "FEDERATION_MUSIC_NEEDS_APPROVAL" default = True
verbose_name = "Federation music needs approval" verbose_name = "Enable public index"
help_text = ( help_text = "If this is enabled, public channels and libraries will be crawlable by other pods and bots"
"When true, other federation actors will need your approval"
" before being able to browse your library."
)
...@@ -2,12 +2,12 @@ import uuid ...@@ -2,12 +2,12 @@ import uuid
import factory import factory
import requests import requests
import requests_http_signature import requests_http_message_signatures
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.http import http_date from django.utils.http import http_date
from funkwhale_api.factories import registry, NoUpdateOnCreate from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.users import factories as user_factories from funkwhale_api.users import factories as user_factories
from . import keys, models from . import keys, models
...@@ -20,11 +20,10 @@ class SignatureAuthFactory(factory.Factory): ...@@ -20,11 +20,10 @@ class SignatureAuthFactory(factory.Factory):
algorithm = "rsa-sha256" algorithm = "rsa-sha256"
key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker("url") key_id = factory.Faker("url")
use_auth_header = False headers = ["(request-target)", "user-agent", "host", "date", "accept"]
headers = ["(request-target)", "user-agent", "host", "date", "content-type"]
class Meta: class Meta:
model = requests_http_signature.HTTPSignatureAuth model = requests_http_message_signatures.HTTPSignatureHeaderAuth
@registry.register(name="federation.SignedRequest") @registry.register(name="federation.SignedRequest")
...@@ -42,7 +41,7 @@ class SignedRequestFactory(factory.Factory): ...@@ -42,7 +41,7 @@ class SignedRequestFactory(factory.Factory):
"User-Agent": "Test", "User-Agent": "Test",
"Host": "test.host", "Host": "test.host",
"Date": http_date(timezone.now().timestamp()), "Date": http_date(timezone.now().timestamp()),
"Content-Type": "application/activity+json", "Accept": "application/activity+json",
} }
if extracted: if extracted:
default_headers.update(extracted) default_headers.update(extracted)
...@@ -70,21 +69,44 @@ def create_user(actor): ...@@ -70,21 +69,44 @@ def create_user(actor):
class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
name = factory.Faker("domain_name") name = factory.Faker("domain_name")
nodeinfo_fetch_date = factory.LazyFunction(lambda: timezone.now()) nodeinfo_fetch_date = factory.LazyFunction(lambda: timezone.now())
allowed = None
reachable = True
last_successful_contact = None
class Meta: class Meta:
model = "federation.Domain" model = "federation.Domain"
django_get_or_create = ("name",) django_get_or_create = ("name",)
@factory.post_generation
def with_service_actor(self, create, extracted, **kwargs):
if not create or not extracted:
return
self.service_actor = ActorFactory(domain=self)
self.save(update_fields=["service_actor"])
return self.service_actor
_CACHE = {}
def get_cached_key_pair():
try:
return _CACHE["keys"]
except KeyError:
_CACHE["keys"] = keys.get_key_pair()
return _CACHE["keys"]
@registry.register @registry.register
class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
public_key = None public_key = None
private_key = None private_key = None
preferred_username = factory.Faker("user_name") preferred_username = factory.Faker("user_name")
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
domain = factory.SubFactory(DomainFactory) domain = factory.SubFactory(DomainFactory)
fid = factory.LazyAttribute( fid = factory.LazyAttribute(
lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username) lambda o: f"https://{o.domain.name}/users/{o.preferred_username}"
) )
followers_url = factory.LazyAttribute( followers_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}followers".format( lambda o: "https://{}/users/{}followers".format(
...@@ -101,7 +123,7 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -101,7 +123,7 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
o.domain.name, o.preferred_username o.domain.name, o.preferred_username
) )
) )
keys = factory.LazyFunction(keys.get_key_pair) keys = factory.LazyFunction(get_cached_key_pair)
class Meta: class Meta:
model = models.Actor model = models.Actor
...@@ -115,7 +137,8 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -115,7 +137,8 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
self.domain = models.Domain.objects.get_or_create( self.domain = models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME name=settings.FEDERATION_HOSTNAME
)[0] )[0]
self.save(update_fields=["domain"]) self.fid = f"https://{self.domain}/actors/{self.preferred_username}"
self.save(update_fields=["domain", "fid"])
if not create: if not create:
if extracted and hasattr(extracted, "pk"): if extracted and hasattr(extracted, "pk"):
extracted.actor = self extracted.actor = self
...@@ -124,12 +147,30 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -124,12 +147,30 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
if extracted and hasattr(extracted, "pk"): if extracted and hasattr(extracted, "pk"):
extracted.actor = self extracted.actor = self
extracted.save(update_fields=["user"]) extracted.save(update_fields=["user"])
else:
@factory.post_generation
def user(self, create, extracted, **kwargs):
"""
Handle the creation or assignment of the related user instance.
If `actor__user` is passed, it will be linked; otherwise, no user is created.
"""
from funkwhale_api.users.factories import UserFactory
if not create:
return
if extracted: # If a User instance is provided
extracted.actor = self
extracted.save(update_fields=["actor"])
elif kwargs:
# Create a User linked to this Actor
self.user = UserFactory(actor=self, **kwargs) self.user = UserFactory(actor=self, **kwargs)
else:
self.user = UserFactory(actor=self)
@registry.register @registry.register
class FollowFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class FollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
target = factory.SubFactory(ActorFactory) target = factory.SubFactory(ActorFactory)
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
...@@ -142,22 +183,30 @@ class FollowFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -142,22 +183,30 @@ class FollowFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register @registry.register
class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
uuid = factory.Faker("uuid4")
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
privacy_level = "me" privacy_level = "me"
name = factory.Faker("sentence") name = privacy_level
description = factory.Faker("sentence")
uploads_count = 0 uploads_count = 0
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
followers_url = factory.LazyAttribute(
lambda o: o.fid + "/followers" if o.fid else None
)
class Meta: class Meta:
model = "music.Library" model = "music.Library"
class Params:
local = factory.Trait(
fid=factory.Faker(
"federation_url",
local=True,
prefix="federation/music/libraries",
obj_uuid=factory.SelfAttribute("..uuid"),
),
actor=factory.SubFactory(ActorFactory, local=True),
)
@registry.register @registry.register
class LibraryScan(NoUpdateOnCreate, factory.django.DjangoModelFactory): class LibraryScanFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
library = factory.SubFactory(MusicLibraryFactory) library = factory.SubFactory(MusicLibraryFactory)
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
total_files = factory.LazyAttribute(lambda o: o.library.uploads_count) total_files = factory.LazyAttribute(lambda o: o.library.uploads_count)
...@@ -166,6 +215,14 @@ class LibraryScan(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -166,6 +215,14 @@ class LibraryScan(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "music.LibraryScan" model = "music.LibraryScan"
@registry.register
class FetchFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
actor = factory.SubFactory(ActorFactory)
class Meta:
model = "federation.Fetch"
@registry.register @registry.register
class ActivityFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class ActivityFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
...@@ -196,7 +253,7 @@ class DeliveryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -196,7 +253,7 @@ class DeliveryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@registry.register @registry.register
class LibraryFollowFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class LibraryFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
target = factory.SubFactory(MusicLibraryFactory) target = factory.SubFactory(MusicLibraryFactory)
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
...@@ -259,13 +316,13 @@ class NoteFactory(factory.Factory): ...@@ -259,13 +316,13 @@ class NoteFactory(factory.Factory):
@registry.register(name="federation.AudioMetadata") @registry.register(name="federation.AudioMetadata")
class AudioMetadataFactory(factory.Factory): class AudioMetadataFactory(factory.Factory):
recording = factory.LazyAttribute( recording = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/recording/{}".format(uuid.uuid4()) lambda o: f"https://musicbrainz.org/recording/{uuid.uuid4()}"
) )
artist = factory.LazyAttribute( artist = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/artist/{}".format(uuid.uuid4()) lambda o: f"https://musicbrainz.org/artist/{uuid.uuid4()}"
) )
release = factory.LazyAttribute( release = factory.LazyAttribute(
lambda o: "https://musicbrainz.org/release/{}".format(uuid.uuid4()) lambda o: f"https://musicbrainz.org/release/{uuid.uuid4()}"
) )
bitrate = 42 bitrate = 42
length = 43 length = 43
......
import django_filters
from rest_framework import serializers from rest_framework import serializers
from . import models from . import models, utils
class ActorRelatedField(serializers.EmailField): class ActorRelatedField(serializers.EmailField):
...@@ -16,3 +17,15 @@ class ActorRelatedField(serializers.EmailField): ...@@ -16,3 +17,15 @@ class ActorRelatedField(serializers.EmailField):
) )
except models.Actor.DoesNotExist: except models.Actor.DoesNotExist:
raise serializers.ValidationError("Invalid actor name") raise serializers.ValidationError("Invalid actor name")
class DomainFromURLFilter(django_filters.CharFilter):
def __init__(self, *args, **kwargs):
self.url_field = kwargs.pop("url_field", "fid")
super().__init__(*args, **kwargs)
def filter(self, qs, value):
if not value:
return qs
query = utils.get_domain_query_from_url(value, self.url_field)
return qs.filter(query)
...@@ -20,7 +20,7 @@ class FollowFilter(django_filters.FilterSet): ...@@ -20,7 +20,7 @@ class FollowFilter(django_filters.FilterSet):
class Meta: class Meta:
model = models.Follow model = models.Follow
fields = ["approved", "pending", "q"] fields = ["approved"]
def filter_pending(self, queryset, field_name, value): def filter_pending(self, queryset, field_name, value):
if value.lower() in ["true", "1", "yes"]: if value.lower() in ["true", "1", "yes"]:
...@@ -46,3 +46,14 @@ class InboxItemFilter(django_filters.FilterSet): ...@@ -46,3 +46,14 @@ class InboxItemFilter(django_filters.FilterSet):
def filter_before(self, queryset, field_name, value): def filter_before(self, queryset, field_name, value):
return queryset.filter(pk__lte=value) return queryset.filter(pk__lte=value)
class FetchFilter(django_filters.FilterSet):
ordering = django_filters.OrderingFilter(
# tuple-mapping retains order
fields=(("creation_date", "creation_date"), ("fetch_date", "fetch_date"))
)
class Meta:
model = models.Fetch
fields = ["status", "object_id", "url"]
import aiohttp
import asyncio import asyncio
import functools import functools
import logging
import aiohttp
import pyld.documentloader.requests
import pyld.jsonld import pyld.jsonld
from django.conf import settings from django.conf import settings
import pyld.documentloader.requests
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import empty from rest_framework.fields import empty
from . import contexts from . import contexts
logger = logging.getLogger(__name__)
def cached_contexts(loader): def cached_contexts(loader):
functools.wraps(loader) functools.wraps(loader)
...@@ -17,6 +21,10 @@ def cached_contexts(loader): ...@@ -17,6 +21,10 @@ def cached_contexts(loader):
for cached in contexts.CONTEXTS: for cached in contexts.CONTEXTS:
if url == cached["documentUrl"]: if url == cached["documentUrl"]:
return cached return cached
if cached["shortId"] == "LITEPUB" and "/schemas/litepub-" in url:
# XXX UGLY fix for pleroma because they host their schema
# under each instance domain, which makes caching harder
return cached
return loader(url, *args, **kwargs) return loader(url, *args, **kwargs)
return load return load
...@@ -29,18 +37,30 @@ def get_document_loader(): ...@@ -29,18 +37,30 @@ def get_document_loader():
return cached_contexts(loader) return cached_contexts(loader)
def expand(doc, options=None, insert_fw_context=True): def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]):
options = options or {} options = options or {}
options.setdefault("documentLoader", get_document_loader()) options.setdefault("documentLoader", get_document_loader())
if isinstance(doc, str): if isinstance(doc, str):
doc = options["documentLoader"](doc)["document"] doc = options["documentLoader"](doc)["document"]
if insert_fw_context: for context_name in default_contexts:
fw = contexts.CONTEXTS_BY_ID["FW"]["documentUrl"] ctx = contexts.CONTEXTS_BY_ID[context_name]["documentUrl"]
try: try:
insert_context(fw, doc) insert_context(ctx, doc)
except KeyError: except KeyError:
# probably an already expanded document # probably an already expanded document
pass pass
# XXX This is a hotfix for a bug in pyld. The JSON-LD allows empty dicts or lists as part of the
# context, but this makes pyld failing to parse the context the right way. So we remove all
# empty items from the contexts
try:
for active_ctx in doc["@context"]:
if len(active_ctx) == 0:
doc["@context"].remove(active_ctx)
except KeyError:
# Nothing to do here if no context is available at all
pass
result = pyld.jsonld.expand(doc, options=options) result = pyld.jsonld.expand(doc, options=options)
try: try:
# jsonld.expand returns a list, which is useless for us # jsonld.expand returns a list, which is useless for us
...@@ -57,7 +77,9 @@ def insert_context(ctx, doc): ...@@ -57,7 +77,9 @@ def insert_context(ctx, doc):
existing = doc["@context"] existing = doc["@context"]
if isinstance(existing, list): if isinstance(existing, list):
if ctx not in existing: if ctx not in existing:
existing = existing[:]
existing.append(ctx) existing.append(ctx)
doc["@context"] = existing
else: else:
doc["@context"] = [existing, ctx] doc["@context"] = [existing, ctx]
return doc return doc
...@@ -77,7 +99,7 @@ async def fetch_many(*ids, references=None): ...@@ -77,7 +99,7 @@ async def fetch_many(*ids, references=None):
""" """
Given a list of object ids, will fetch the remote Given a list of object ids, will fetch the remote
representations for those objects, expand them representations for those objects, expand them
and return a dictionnary with id as the key and expanded document as the values and return a dictionary with id as the key and expanded document as the values
""" """
ids = set(ids) ids = set(ids)
results = references if references is not None else {} results = references if references is not None else {}
...@@ -103,7 +125,7 @@ DEFAULT_PREPARE_CONFIG = { ...@@ -103,7 +125,7 @@ DEFAULT_PREPARE_CONFIG = {
def dereference(value, references): def dereference(value, references):
""" """
Given a payload and a dictonary containing ids and objects, will replace Given a payload and a dictionary containing ids and objects, will replace
all the matching objects in the payload by the one in the references dictionary. all the matching objects in the payload by the one in the references dictionary.
""" """
...@@ -132,7 +154,6 @@ def dereference(value, references): ...@@ -132,7 +154,6 @@ def dereference(value, references):
def get_value(value, keep=None, attr=None): def get_value(value, keep=None, attr=None):
if keep == "first": if keep == "first":
value = value[0] value = value[0]
if attr: if attr:
...@@ -147,10 +168,10 @@ def get_value(value, keep=None, attr=None): ...@@ -147,10 +168,10 @@ def get_value(value, keep=None, attr=None):
def prepare_for_serializer(payload, config, fallbacks={}): def prepare_for_serializer(payload, config, fallbacks={}):
""" """
Json-ld payloads, as returned by expand are quite complex to handle, because Json-ld payloads, as returned by expand are quite complex to handle, because
every attr is basically a list of dictionnaries. To make code simpler, every attr is basically a list of dictionaries. To make code simpler,
we use this function to clean the payload a little bit, base on the config object. we use this function to clean the payload a little bit, base on the config object.
Config is a dictionnary, with keys being serializer field names, and values Config is a dictionary, with keys being serializer field names, and values
being dictionaries describing how to handle this field. being dictionaries describing how to handle this field.
""" """
final_payload = {} final_payload = {}
...@@ -165,18 +186,17 @@ def prepare_for_serializer(payload, config, fallbacks={}): ...@@ -165,18 +186,17 @@ def prepare_for_serializer(payload, config, fallbacks={}):
attr=field_config.get("attr"), attr=field_config.get("attr"),
) )
except (IndexError, KeyError): except (IndexError, KeyError):
aliases = field_config.get("aliases", []) aliases = field_config.get("aliases", {})
noop = object() noop = object()
value = noop value = noop
if not aliases: if not aliases:
continue continue
for a in aliases: for a in aliases:
try: try:
value = get_value( value = get_value(
payload[a], payload[a["property"]],
keep=field_config.get("keep"), keep=a.get("keep"),
attr=field_config.get("attr"), attr=a.get("attr"),
) )
except (IndexError, KeyError): except (IndexError, KeyError):
continue continue
...@@ -212,17 +232,32 @@ def get_ids(v): ...@@ -212,17 +232,32 @@ def get_ids(v):
def get_default_context(): def get_default_context():
return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}] return [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://funkwhale.audio/ns",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
},
]
class JsonLdSerializer(serializers.Serializer): class JsonLdSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
self.jsonld_expand = kwargs.pop("jsonld_expand", True)
super().__init__(*args, **kwargs)
self.jsonld_context = []
def run_validation(self, data=empty): def run_validation(self, data=empty):
if data and data is not empty and self.context.get("expand", True): if data and data is not empty:
self.jsonld_context = data.get("@context", [])
if self.context.get("expand", self.jsonld_expand):
try: try:
data = expand(data) data = expand(data)
except ValueError: except ValueError as e:
raise serializers.ValidationError( raise serializers.ValidationError(
"{} is not a valid jsonld document".format(data) f"{data} is not a valid jsonld document: {e}"
) )
try: try:
config = self.Meta.jsonld_mapping config = self.Meta.jsonld_mapping
...@@ -232,6 +267,7 @@ class JsonLdSerializer(serializers.Serializer): ...@@ -232,6 +267,7 @@ class JsonLdSerializer(serializers.Serializer):
fallbacks = self.Meta.jsonld_fallbacks fallbacks = self.Meta.jsonld_fallbacks
except AttributeError: except AttributeError:
fallbacks = {} fallbacks = {}
data = prepare_for_serializer(data, config, fallbacks=fallbacks) data = prepare_for_serializer(data, config, fallbacks=fallbacks)
dereferenced_fields = [ dereferenced_fields = [
k k
...@@ -242,11 +278,11 @@ class JsonLdSerializer(serializers.Serializer): ...@@ -242,11 +278,11 @@ class JsonLdSerializer(serializers.Serializer):
for field in dereferenced_fields: for field in dereferenced_fields:
for i in get_ids(data[field]): for i in get_ids(data[field]):
dereferenced_ids.add(i) dereferenced_ids.add(i)
if dereferenced_ids: if dereferenced_ids:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
except RuntimeError: except RuntimeError as exception:
logger.debug(exception)
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
references = self.context.setdefault("references", {}) references = self.context.setdefault("references", {})
loop.run_until_complete( loop.run_until_complete(
...@@ -274,3 +310,15 @@ def first_obj(property, aliases=[]): ...@@ -274,3 +310,15 @@ def first_obj(property, aliases=[]):
def raw(property, aliases=[]): def raw(property, aliases=[]):
return {"property": property, "aliases": aliases} return {"property": property, "aliases": aliases}
def is_present_recursive(data, key):
if isinstance(data, (dict, list)):
for v in data:
if is_present_recursive(v, key):
return True
else:
if data == key:
return True
return False
import re import re
import urllib.parse import urllib.parse
from django.conf import settings
from cryptography.hazmat.backends import default_backend as crypto_default_backend from cryptography.hazmat.backends import default_backend as crypto_default_backend
from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from django.conf import settings
KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"") KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"")
...@@ -21,7 +20,8 @@ def get_key_pair(size=None): ...@@ -21,7 +20,8 @@ def get_key_pair(size=None):
crypto_serialization.NoEncryption(), crypto_serialization.NoEncryption(),
) )
public_key = key.public_key().public_bytes( public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.PKCS1 crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.SubjectPublicKeyInfo,
) )
return private_key, public_key return private_key, public_key
......