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
  • 1.0.1
  • 1108-remove-jwt-and-switch-to-oauth-for-ui-auth
  • 1121-download
  • 1278-embed-isn-t-available-in-the-front-end-for-channel-tracks
  • 1299-encoding-problem-in-rss-feeds
  • 1303-failing-to-refetch-federated-tracks
  • 1311-feedparser-requires-update-to-accomodate-python-3-9
  • 1346-selectoreventloop-required-instead-got-uvloop-loop
  • 1356-update-packages
  • develop
  • master
  • plugins
  • plugins-v2
  • plugins-v3
  • set-sast-config-1
  • set-sast-config-2
  • tracemallocmiddleware
  • 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
79 results
Show changes
Showing
with 498 additions and 153 deletions
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):
......
import datetime import datetime
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core import validators from django.core import validators
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone 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 models as audio_models
from funkwhale_api.common import fields as common_fields 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 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
...@@ -44,8 +46,9 @@ class DomainSerializer(serializers.Serializer): ...@@ -44,8 +46,9 @@ class DomainSerializer(serializers.Serializer):
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
...@@ -54,7 +57,6 @@ class LibrarySerializer(serializers.ModelSerializer): ...@@ -54,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",
...@@ -62,20 +64,16 @@ class LibrarySerializer(serializers.ModelSerializer): ...@@ -62,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)
...@@ -95,6 +93,31 @@ class LibraryFollowSerializer(serializers.ModelSerializer): ...@@ -95,6 +93,31 @@ 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
...@@ -108,16 +131,18 @@ def serialize_generic_relation(activity, obj): ...@@ -108,16 +131,18 @@ def serialize_generic_relation(activity, obj):
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:
...@@ -135,14 +160,17 @@ class ActivitySerializer(serializers.ModelSerializer): ...@@ -135,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)
...@@ -165,21 +193,32 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer): ...@@ -165,21 +193,32 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer):
return objects.update(is_read=True) return objects.update(is_read=True)
FETCH_OBJECT_CONFIG = { OBJECT_SERIALIZER_MAPPING = {
"artist": {"queryset": music_models.Artist.objects.all()}, music_models.Artist: federation_serializers.ArtistSerializer,
"album": {"queryset": music_models.Album.objects.all()}, music_models.Album: federation_serializers.AlbumSerializer,
"track": {"queryset": music_models.Track.objects.all()}, music_models.Track: federation_serializers.TrackSerializer,
"library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"}, models.Actor: federation_serializers.APIActorSerializer,
"upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"}, audio_models.Channel: audio_serializers.ChannelSerializer,
"account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"}, playlists_models.Playlist: federation_serializers.PlaylistSerializer,
"channel": {"queryset": audio_models.Channel.objects.all(), "id_attr": "uuid"},
} }
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
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): class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
object = serializers.CharField(write_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) force = serializers.BooleanField(default=False, required=False, write_only=True)
class Meta: class Meta:
...@@ -192,8 +231,10 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -192,8 +231,10 @@ class FetchSerializer(serializers.ModelSerializer):
"detail", "detail",
"creation_date", "creation_date",
"fetch_date", "fetch_date",
"object", "object_uri",
"force", "force",
"type",
"object",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
...@@ -203,10 +244,36 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -203,10 +244,36 @@ class FetchSerializer(serializers.ModelSerializer):
"detail", "detail",
"creation_date", "creation_date",
"fetch_date", "fetch_date",
"type",
"object",
] ]
def validate_object(self, value): def get_type(self, fetch):
# if value is a webginfer lookup, we craft a special url 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("@"): if value.startswith("@"):
value = value.lstrip("@") value = value.lstrip("@")
validator = validators.EmailValidator() validator = validators.EmailValidator()
...@@ -214,8 +281,29 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -214,8 +281,29 @@ class FetchSerializer(serializers.ModelSerializer):
validator(value) validator(value)
except validators.ValidationError: except validators.ValidationError:
return value 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
return "webfinger://{}".format(value) serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj))
if serializer_class:
return serializer_class(obj).data
return None
def create(self, validated_data): def create(self, validated_data):
check_duplicates = not validated_data.get("force", False) check_duplicates = not validated_data.get("force", False)
...@@ -225,7 +313,7 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -225,7 +313,7 @@ class FetchSerializer(serializers.ModelSerializer):
validated_data["actor"] validated_data["actor"]
.fetches.filter( .fetches.filter(
status="finished", status="finished",
url=validated_data["object"], url=validated_data["object_uri"],
creation_date__gte=timezone.now() creation_date__gte=timezone.now()
- datetime.timedelta( - datetime.timedelta(
seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY
...@@ -238,18 +326,10 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -238,18 +326,10 @@ class FetchSerializer(serializers.ModelSerializer):
return duplicate return duplicate
fetch = models.Fetch.objects.create( fetch = models.Fetch.objects.create(
actor=validated_data["actor"], url=validated_data["object"] actor=validated_data["actor"], url=validated_data["object_uri"]
) )
return fetch return fetch
def to_representation(self, obj):
repr = super().to_representation(obj)
object_data = None
if obj.object:
object_data = FETCH_OBJECT_FIELD.to_representation(obj.object)
repr["object"] = object_data
return repr
class FullActorSerializer(serializers.Serializer): class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField() fid = serializers.URLField()
...@@ -268,6 +348,7 @@ class FullActorSerializer(serializers.Serializer): ...@@ -268,6 +348,7 @@ class FullActorSerializer(serializers.Serializer):
summary = common_serializers.ContentSerializer(source="summary_obj") summary = common_serializers.ContentSerializer(source="summary_obj")
icon = common_serializers.AttachmentSerializer(source="attachment_icon") icon = common_serializers.AttachmentSerializer(source="attachment_icon")
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_channel(self, o): def get_is_channel(self, o):
try: try:
return bool(o.channel) return bool(o.channel)
......
...@@ -5,6 +5,7 @@ from . import api_views ...@@ -5,6 +5,7 @@ from . import api_views
router = routers.OptionalSlashRouter() router = routers.OptionalSlashRouter()
router.register(r"fetches", api_views.FetchViewSet, "fetches") 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"domains", api_views.DomainViewSet, "domains")
......
import requests.exceptions import requests.exceptions
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Count, Q 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 preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common.permissions import ConditionalAuthentication 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.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions 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 tasks serializers,
from . import utils tasks,
utils,
)
@transaction.atomic @transaction.atomic
...@@ -38,6 +37,10 @@ def update_follow(follow, approved): ...@@ -38,6 +37,10 @@ def update_follow(follow, approved):
routes.outbox.dispatch({"type": "Reject"}, context={"follow": follow}) 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,
...@@ -57,6 +60,14 @@ class LibraryFollowViewSet( ...@@ -57,6 +60,14 @@ class LibraryFollowViewSet(
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).exclude(approved=False) return qs.filter(actor=self.request.user.actor).exclude(approved=False)
...@@ -77,6 +88,10 @@ class LibraryFollowViewSet( ...@@ -77,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:
...@@ -88,6 +103,7 @@ class LibraryFollowViewSet( ...@@ -88,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:
...@@ -100,6 +116,7 @@ class LibraryFollowViewSet( ...@@ -100,6 +116,7 @@ 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) @decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs): def all(self, request, *args, **kwargs):
""" """
...@@ -174,12 +191,12 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -174,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)
...@@ -192,7 +209,6 @@ class InboxItemViewSet( ...@@ -192,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")
...@@ -223,7 +239,6 @@ class InboxItemViewSet( ...@@ -223,7 +239,6 @@ class InboxItemViewSet(
class FetchViewSet( class FetchViewSet(
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
queryset = models.Fetch.objects.select_related("actor") queryset = models.Fetch.objects.select_related("actor")
serializer_class = api_serializers.FetchSerializer serializer_class = api_serializers.FetchSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
...@@ -275,7 +290,12 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -275,7 +290,12 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
def get_object(self): def get_object(self):
queryset = self.get_queryset() queryset = self.get_queryset()
username, domain = self.kwargs["full_username"].split("@", 1) username, domain = self.kwargs["full_username"].split("@", 1)
try:
return queryset.get(preferred_username=username, domain_id=domain) 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): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
...@@ -288,8 +308,115 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -288,8 +308,115 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
qs = qs.filter(query) qs = qs.filter(query)
return qs return qs
libraries = decorators.action(methods=["get"], detail=True)( libraries = decorators.action(
methods=["get"],
detail=True,
serializer_class=music_serializers.LibraryForOwnerSerializer,
)(
music_views.get_libraries( music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o) 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 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.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, models, signing, tasks, utils
from . import actors, exceptions, keys, models, signing, tasks, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -53,7 +55,9 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -53,7 +55,9 @@ class SignatureAuthentication(authentication.BaseAuthentication):
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 actor/domain %s, %s", actor_url, str(e), "Discarding HTTP request from actor/domain %s, %s",
actor_url,
str(e),
) )
raise rest_exceptions.AuthenticationFailed( raise rest_exceptions.AuthenticationFailed(
"Cannot fetch remote actor to authenticate signature" "Cannot fetch remote actor to authenticate signature"
...@@ -77,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -77,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))
): ):
......
...@@ -293,7 +293,10 @@ CONTEXTS = [ ...@@ -293,7 +293,10 @@ CONTEXTS = [
"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"},
...@@ -302,13 +305,23 @@ CONTEXTS = [ ...@@ -302,13 +305,23 @@ 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", "category": "schema:category",
"language": "schema:inLanguage", "language": "schema:inLanguage",
"playlist": {"@id": "fw:playlist", "@type": "@id"},
} }
}, },
}, },
...@@ -362,14 +375,14 @@ class NS: ...@@ -362,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()
......
from django.db import transaction from django.db import transaction
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import decorators from rest_framework import decorators, permissions, response, status
from rest_framework import permissions
from rest_framework import response
from rest_framework import status
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from . import api_serializers from . import api_serializers, filters, models, tasks, utils
from . import filters
from . import models
from . import tasks
from . import utils
def fetches_route(): def fetches_route():
...@@ -42,8 +35,16 @@ def fetches_route(): ...@@ -42,8 +35,16 @@ def fetches_route():
serializer = api_serializers.FetchSerializer(fetch) serializer = api_serializers.FetchSerializer(fetch)
return response.Response(serializer.data, status=status.HTTP_201_CREATED) return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action( 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"], methods=["get", "post"],
detail=True, detail=True,
permission_classes=[permissions.IsAuthenticated], permission_classes=[permissions.IsAuthenticated],
)(fetches) )(fetches)
)
)
...@@ -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", "accept"]
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")
...@@ -71,6 +70,8 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -71,6 +70,8 @@ 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 allowed = None
reachable = True
last_successful_contact = None
class Meta: class Meta:
model = "federation.Domain" model = "federation.Domain"
...@@ -98,14 +99,14 @@ def get_cached_key_pair(): ...@@ -98,14 +99,14 @@ def get_cached_key_pair():
@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(
...@@ -127,9 +128,6 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -127,9 +128,6 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class Meta: class Meta:
model = models.Actor model = models.Actor
class Params:
with_real_keys = factory.Trait(keys=factory.LazyFunction(keys.get_key_pair),)
@factory.post_generation @factory.post_generation
def local(self, create, extracted, **kwargs): def local(self, create, extracted, **kwargs):
if not extracted and not kwargs: if not extracted and not kwargs:
...@@ -139,7 +137,7 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -139,7 +137,7 @@ 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.fid = "https://{}/actors/{}".format(self.domain, self.preferred_username) self.fid = f"https://{self.domain}/actors/{self.preferred_username}"
self.save(update_fields=["domain", "fid"]) self.save(update_fields=["domain", "fid"])
if not create: if not create:
if extracted and hasattr(extracted, "pk"): if extracted and hasattr(extracted, "pk"):
...@@ -149,12 +147,30 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -149,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)
...@@ -167,22 +183,25 @@ class FollowFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -167,22 +183,25 @@ 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: class Params:
local = factory.Trait( local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True) fid=factory.Faker(
"federation_url",
local=True,
prefix="federation/music/libraries",
obj_uuid=factory.SelfAttribute("..uuid"),
),
actor=factory.SubFactory(ActorFactory, local=True),
) )
...@@ -234,7 +253,7 @@ class DeliveryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): ...@@ -234,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)
...@@ -297,13 +316,13 @@ class NoteFactory(factory.Factory): ...@@ -297,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 import django_filters
from rest_framework import serializers from rest_framework import serializers
from . import models from . import models, utils
from . import utils
class ActorRelatedField(serializers.EmailField): class ActorRelatedField(serializers.EmailField):
......
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)
...@@ -46,6 +50,17 @@ def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]): ...@@ -46,6 +50,17 @@ def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]):
# 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
...@@ -84,7 +99,7 @@ async def fetch_many(*ids, references=None): ...@@ -84,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 {}
...@@ -110,7 +125,7 @@ DEFAULT_PREPARE_CONFIG = { ...@@ -110,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.
""" """
...@@ -139,7 +154,6 @@ def dereference(value, references): ...@@ -139,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:
...@@ -154,10 +168,10 @@ def get_value(value, keep=None, attr=None): ...@@ -154,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 = {}
...@@ -177,11 +191,12 @@ def prepare_for_serializer(payload, config, fallbacks={}): ...@@ -177,11 +191,12 @@ def prepare_for_serializer(payload, config, fallbacks={}):
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["property"]], keep=a.get("keep"), attr=a.get("attr"), payload[a["property"]],
keep=a.get("keep"),
attr=a.get("attr"),
) )
except (IndexError, KeyError): except (IndexError, KeyError):
continue continue
...@@ -236,14 +251,13 @@ class JsonLdSerializer(serializers.Serializer): ...@@ -236,14 +251,13 @@ class JsonLdSerializer(serializers.Serializer):
def run_validation(self, data=empty): def run_validation(self, data=empty):
if data and data is not empty: if data and data is not empty:
self.jsonld_context = data.get("@context", []) self.jsonld_context = data.get("@context", [])
if self.context.get("expand", self.jsonld_expand): if self.context.get("expand", self.jsonld_expand):
try: try:
data = expand(data) data = expand(data)
except ValueError as e: except ValueError as e:
raise serializers.ValidationError( raise serializers.ValidationError(
"{} is not a valid jsonld document: {}".format(data, e) f"{data} is not a valid jsonld document: {e}"
) )
try: try:
config = self.Meta.jsonld_mapping config = self.Meta.jsonld_mapping
...@@ -264,11 +278,11 @@ class JsonLdSerializer(serializers.Serializer): ...@@ -264,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(
......
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>.*)\"")
......
...@@ -9,7 +9,9 @@ def get_library_data(library_url, actor): ...@@ -9,7 +9,9 @@ def get_library_data(library_url, actor):
auth = signing.get_auth(actor.private_key, actor.private_key_id) auth = signing.get_auth(actor.private_key, actor.private_key_id)
try: try:
response = session.get_session().get( response = session.get_session().get(
library_url, auth=auth, headers={"Accept": "application/activity+json"}, library_url,
auth=auth,
headers={"Accept": "application/activity+json"},
) )
except requests.ConnectionError: except requests.ConnectionError:
return {"errors": ["This library is not reachable"]} return {"errors": ["This library is not reachable"]}
...@@ -19,7 +21,7 @@ def get_library_data(library_url, actor): ...@@ -19,7 +21,7 @@ def get_library_data(library_url, actor):
elif scode == 403: elif scode == 403:
return {"errors": ["Permission denied while scanning library"]} return {"errors": ["Permission denied while scanning library"]}
elif scode >= 400: elif scode >= 400:
return {"errors": ["Error {} while fetching the library".format(scode)]} return {"errors": [f"Error {scode} while fetching the library"]}
serializer = serializers.LibrarySerializer(data=response.json()) serializer = serializers.LibrarySerializer(data=response.json())
if not serializer.is_valid(): if not serializer.is_valid():
return {"errors": ["Invalid ActivityPub response from remote library"]} return {"errors": ["Invalid ActivityPub response from remote library"]}
...@@ -30,7 +32,9 @@ def get_library_data(library_url, actor): ...@@ -30,7 +32,9 @@ def get_library_data(library_url, actor):
def get_library_page(library, page_url, actor): def get_library_page(library, page_url, actor):
auth = signing.get_auth(actor.private_key, actor.private_key_id) auth = signing.get_auth(actor.private_key, actor.private_key_id)
response = session.get_session().get( response = session.get_session().get(
page_url, auth=auth, headers={"Accept": "application/activity+json"}, page_url,
auth=auth,
headers={"Accept": "application/activity+json"},
) )
serializer = serializers.CollectionPageSerializer( serializer = serializers.CollectionPageSerializer(
data=response.json(), data=response.json(),
......
...@@ -4,13 +4,12 @@ from funkwhale_api.common import utils ...@@ -4,13 +4,12 @@ from funkwhale_api.common import utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
MODELS = [ MODELS = [
(music_models.Artist, ["fid"]), (music_models.Artist, ["fid"]),
(music_models.Album, ["fid"]), (music_models.Album, ["fid"]),
(music_models.Track, ["fid"]), (music_models.Track, ["fid"]),
(music_models.Upload, ["fid"]), (music_models.Upload, ["fid"]),
(music_models.Library, ["fid", "followers_url"]), (music_models.Library, ["fid"]),
( (
federation_models.Actor, federation_models.Actor,
[ [
...@@ -31,7 +30,7 @@ MODELS = [ ...@@ -31,7 +30,7 @@ MODELS = [
class Command(BaseCommand): class Command(BaseCommand):
help = """ help = """
Find and replace wrong protocal/domain in local federation ids. Find and replace wrong protocol/domain in local federation ids.
Use with caution and only if you know what you are doing. Use with caution and only if you know what you are doing.
""" """
...@@ -68,9 +67,7 @@ class Command(BaseCommand): ...@@ -68,9 +67,7 @@ class Command(BaseCommand):
for kls, fields in MODELS: for kls, fields in MODELS:
results[kls] = {} results[kls] = {}
for field in fields: for field in fields:
candidates = kls.objects.filter( candidates = kls.objects.filter(**{f"{field}__startswith": old_prefix})
**{"{}__startswith".format(field): old_prefix}
)
results[kls][field] = candidates.count() results[kls][field] = candidates.count()
total = sum([t for k in results.values() for t in k.values()]) total = sum([t for k in results.values() for t in k.values()])
...@@ -93,9 +90,7 @@ class Command(BaseCommand): ...@@ -93,9 +90,7 @@ class Command(BaseCommand):
) )
else: else:
self.stdout.write( self.stdout.write(f"No objects found with prefix {old_prefix}, exiting.")
"No objects found with prefix {}, exiting.".format(old_prefix)
)
return return
if options["dry_run"]: if options["dry_run"]:
self.stdout.write( self.stdout.write(
...@@ -113,9 +108,7 @@ class Command(BaseCommand): ...@@ -113,9 +108,7 @@ class Command(BaseCommand):
for kls, fields in results.items(): for kls, fields in results.items():
for field, count in fields.items(): for field, count in fields.items():
self.stdout.write( self.stdout.write(f"Replacing {field} on {count} {kls._meta.label}")
"Replacing {} on {} {}…".format(field, count, kls._meta.label)
)
candidates = kls.objects.all() candidates = kls.objects.all()
utils.replace_prefix(candidates, field, old=old_prefix, new=new_prefix) utils.replace_prefix(candidates, field, old=old_prefix, new=new_prefix)
self.stdout.write("") self.stdout.write("")
......
...@@ -75,7 +75,7 @@ class Migration(migrations.Migration): ...@@ -75,7 +75,7 @@ class Migration(migrations.Migration):
"last_fetch_date", "last_fetch_date",
models.DateTimeField(default=django.utils.timezone.now), models.DateTimeField(default=django.utils.timezone.now),
), ),
("manually_approves_followers", models.NullBooleanField(default=None)), ("manually_approves_followers", models.BooleanField(default=None, null=True)),
], ],
) )
] ]
...@@ -77,7 +77,7 @@ class Migration(migrations.Migration): ...@@ -77,7 +77,7 @@ class Migration(migrations.Migration):
models.DateTimeField(default=django.utils.timezone.now), models.DateTimeField(default=django.utils.timezone.now),
), ),
("modification_date", models.DateTimeField(auto_now=True)), ("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.NullBooleanField(default=None)), ("approved", models.BooleanField(default=None, null=True)),
( (
"actor", "actor",
models.ForeignKey( models.ForeignKey(
......
...@@ -14,7 +14,7 @@ class Migration(migrations.Migration): ...@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="follow", model_name="follow",
name="approved", name="approved",
field=models.NullBooleanField(default=None), field=models.BooleanField(default=None, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name="library", model_name="library",
......
...@@ -43,7 +43,7 @@ class Migration(migrations.Migration): ...@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
"creation_date", "creation_date",
models.DateTimeField(default=django.utils.timezone.now), models.DateTimeField(default=django.utils.timezone.now),
), ),
("delivered", models.NullBooleanField(default=None)), ("delivered", models.BooleanField(default=None, null=True)),
("delivered_date", models.DateTimeField(blank=True, null=True)), ("delivered_date", models.DateTimeField(blank=True, null=True)),
], ],
), ),
...@@ -69,7 +69,7 @@ class Migration(migrations.Migration): ...@@ -69,7 +69,7 @@ class Migration(migrations.Migration):
models.DateTimeField(default=django.utils.timezone.now), models.DateTimeField(default=django.utils.timezone.now),
), ),
("modification_date", models.DateTimeField(auto_now=True)), ("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.NullBooleanField(default=None)), ("approved", models.BooleanField(default=None, null=True)),
], ],
), ),
migrations.RenameField("actor", "url", "fid"), migrations.RenameField("actor", "url", "fid"),
......
# Generated by Django 3.2.13 on 2022-06-27 19:15
import django.core.serializers.json
from django.db import migrations, models
import funkwhale_api.federation.models
class Migration(migrations.Migration):
dependencies = [
('federation', '0026_public_key_format'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='payload',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='actor',
name='manually_approves_followers',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='domain',
name='nodeinfo',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, max_length=50000),
),
migrations.AlterField(
model_name='fetch',
name='detail',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='follow',
name='approved',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='libraryfollow',
name='approved',
field=models.BooleanField(default=None, null=True),
),
migrations.AlterField(
model_name='librarytrack',
name='metadata',
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
),
]
# Generated by Django 3.2.16 on 2022-10-27 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('federation', '0027_auto_20220627_1915'),
]
operations = [
migrations.AddField(
model_name='domain',
name='last_successful_contact',
field=models.DateTimeField(default=None, null=True),
),
migrations.AddField(
model_name='domain',
name='reachable',
field=models.BooleanField(default=True),
),
]
# Generated by Django 5.1.6 on 2025-08-04 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("federation", "0028_auto_20221027_1141"),
("music", "0061_migrate_libraries_to_playlist"),
]
operations = [
migrations.AddField(
model_name="domain",
name="reachable_retries",
field=models.PositiveIntegerField(default=0),
),
]