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
  • 2448-complete-tags
  • 2451-delete-no-user-query
  • 2452-fetch-third-party-metadata
  • 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
  • fix-amd64-docker-build-gfortran
  • 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
  • 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
  • wip/2091-improve-visuals
  • wvffle/dependency-maintenance
  • wvffle/new-upload-process
  • wvffle/ui-rewrite-sidebar
  • wvffle/ui-sidebar
  • 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
190 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
  • 170-federation
  • 594-navigation-redesign
  • 735-table-truncate
  • 839-donation-link
  • 865-sql-optimization
  • 890-notification
  • 925-flac-transcoding
  • add-new-shortcuts
  • develop
  • landing-page
  • limit-album-tracks
  • live-streaming
  • master
  • ollie/funkwhale-documentation-fixes
  • plugins
  • plugins-v2
  • vuln-testing
  • 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.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.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
68 results
Show changes
Showing
with 1027 additions and 166 deletions
# Generated by Django 3.2.4 on 2021-07-03 18:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sites', '0003_auto_20171214_2205'),
]
operations = [
migrations.AlterModelOptions(
name='site',
options={'ordering': ['domain'], 'verbose_name': 'site', 'verbose_name_plural': 'sites'},
),
]
import random
import uuid import uuid
import factory import factory
import random
import persisting_theory import persisting_theory
from django.conf import settings from django.conf import settings
from faker.providers import internet as internet_provider from faker.providers import internet as internet_provider
...@@ -315,18 +314,21 @@ class FunkwhaleProvider(internet_provider.Provider): ...@@ -315,18 +314,21 @@ class FunkwhaleProvider(internet_provider.Provider):
not random enough not random enough
""" """
def federation_url(self, prefix="", local=False): def federation_url(self, prefix="", obj_uuid=None, local=False):
if not obj_uuid:
obj_uuid = uuid.uuid4()
def path_generator(): def path_generator():
return "{}/{}".format(prefix, uuid.uuid4()) return f"{prefix}/{obj_uuid}"
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name() domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https" protocol = "https"
path = path_generator() path = path_generator()
return "{}://{}/{}".format(protocol, domain, path) return f"{protocol}://{domain}/{path}"
def user_name(self): def user_name(self):
u = super().user_name() u = super().user_name()
return "{}{}".format(u, random.randint(10, 999)) return f"{u}{random.randint(10, 999)}"
def music_genre(self): def music_genre(self):
return random.choice(TAGS_DATA["genre"]) return random.choice(TAGS_DATA["genre"])
......
...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer) ...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
@record.registry.register_consumer("favorites.TrackFavorite") @record.registry.register_consumer("favorites.TrackFavorite")
def broadcast_track_favorite_to_instance_activity(data, obj): def broadcast_track_favorite_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]: if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send( channels.group_send(
......
...@@ -5,5 +5,5 @@ from . import models ...@@ -5,5 +5,5 @@ from . import models
@admin.register(models.TrackFavorite) @admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin): class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ["user", "track", "creation_date"] list_display = ["actor", "track", "creation_date"]
list_select_related = ["user", "track"] list_select_related = ["actor", "track"]
import factory import factory
from django.conf import settings
from funkwhale_api.factories import registry, NoUpdateOnCreate from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music.factories import TrackFactory from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory
@registry.register @registry.register
class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory): class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory):
track = factory.SubFactory(TrackFactory) track = factory.SubFactory(TrackFactory)
user = factory.SubFactory(UserFactory) actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta: class Meta:
model = "favorites.TrackFavorite" model = "favorites.TrackFavorite"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])
...@@ -9,11 +9,11 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet): ...@@ -9,11 +9,11 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"] search_fields=["track__title", "track__artist__name", "track__album__title"]
) )
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["user", "q", "scope"] fields = []
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"TRACK_FAVORITE" "TRACK_FAVORITE"
] ]
# Generated by Django 3.2.20 on 2023-12-09 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('favorites', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='trackfavorite',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
# Generated by Django 4.2.9 on 2024-03-28 23:32
import uuid
from django.db import migrations, models, transaction
import django.db.models.deletion
from django.conf import settings
from funkwhale_api.federation import utils
from django.urls import reverse
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:likes-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("favorites", "0002_trackfavorite_source"),
]
operations = [
migrations.AddField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
),
),
migrations.AddField(
model_name="trackfavorite",
name="fid",
field=models.URLField(default="https://default.fid"),
preserve_default=False,
),
migrations.AddField(
model_name="trackfavorite",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True, null=False),
),
migrations.AlterField(
model_name="trackfavorite",
name="fid",
field=models.URLField(
db_index=True,
max_length=500,
unique=True,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
), ),
migrations.AlterUniqueTogether(
name="trackfavorite",
unique_together={("track", "actor")},
),
migrations.RemoveField(
model_name="trackfavorite",
name="user",
),
]
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
FAVORITE_PRIVACY_LEVEL_CHOICES = [
(k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]
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(models.Model): 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,
) )
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()
return super().save(**kwargs)
...@@ -3,7 +3,6 @@ from rest_framework import serializers ...@@ -3,7 +3,6 @@ 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.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
...@@ -11,37 +10,40 @@ from . import models ...@@ -11,37 +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)
actor = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date", "actor") fields = ("id", "actor", "track", "creation_date", "actor")
actor = serializers.SerializerMethodField()
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
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 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.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 funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers from . import filters, models, serializers
...@@ -19,10 +21,11 @@ class TrackFavoriteViewSet( ...@@ -19,10 +21,11 @@ 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__actor") queryset = models.TrackFavorite.objects.all().select_related(
"actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
permissions.OwnerPermission, permissions.OwnerPermission,
...@@ -30,19 +33,34 @@ class TrackFavoriteViewSet( ...@@ -30,19 +33,34 @@ class TrackFavoriteViewSet(
required_scope = "favorites" required_scope = "favorites"
anonymous_policy = "setting" 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
) )
...@@ -50,31 +68,56 @@ class TrackFavoriteViewSet( ...@@ -50,31 +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, "actor__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", "attributed_to", "album__attachment_cover"
) )
.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):
""" """
...@@ -83,10 +126,11 @@ class TrackFavoriteViewSet( ...@@ -83,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 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
...@@ -118,20 +118,27 @@ def should_reject(fid, actor_id=None, payload={}): ...@@ -118,20 +118,27 @@ 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 .routes import inbox
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from . import models, serializers, tasks
from .routes import inbox
logger.debug( logger.debug(
"[federation] Received activity from %s : %s", on_behalf_of.fid, activity "[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)
...@@ -159,16 +166,24 @@ def receive(activity, on_behalf_of): ...@@ -159,16 +166,24 @@ def receive(activity, on_behalf_of):
) )
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)
...@@ -209,9 +224,11 @@ class InboxRouter(Router): ...@@ -209,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:
...@@ -229,8 +246,8 @@ class InboxRouter(Router): ...@@ -229,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)
...@@ -252,7 +269,7 @@ class InboxRouter(Router): ...@@ -252,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,
{ {
...@@ -282,6 +299,69 @@ def schedule_key_rotation(actor_id, delay): ...@@ -282,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):
...@@ -291,9 +371,12 @@ class OutboxRouter(Router): ...@@ -291,9 +371,12 @@ class OutboxRouter(Router):
for further delivery. for further delivery.
""" """
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from . import models
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") allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None allowed_domains = None
if allow_list_enabled: if allow_list_enabled:
...@@ -303,6 +386,18 @@ class OutboxRouter(Router): ...@@ -303,6 +386,18 @@ class OutboxRouter(Router):
) )
) )
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):
continue continue
...@@ -386,6 +481,7 @@ class OutboxRouter(Router): ...@@ -386,6 +481,7 @@ 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
...@@ -412,7 +508,7 @@ def is_allowed_url(url, allowed_domains): ...@@ -412,7 +508,7 @@ def is_allowed_url(url, allowed_domains):
def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=None): 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.
...@@ -447,6 +543,13 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non ...@@ -447,6 +543,13 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
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": elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors # we want to broadcast the activity to other instances service actors
...@@ -501,13 +604,6 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non ...@@ -501,13 +604,6 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
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 [
...@@ -529,24 +625,20 @@ def get_actors_from_audience(urls): ...@@ -529,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,14 +13,16 @@ logger = logging.getLogger(__name__) ...@@ -13,14 +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, headers={"Accept": "application/activity+json"}, actor_url,
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):
...@@ -42,21 +44,32 @@ def get_actor(fid, skip_cache=False): ...@@ -42,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):
......
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)
...@@ -146,8 +193,33 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer): ...@@ -146,8 +193,33 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer):
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): class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer() 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: class Meta:
model = models.Fetch model = models.Fetch
...@@ -159,4 +231,126 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -159,4 +231,126 @@ class FetchSerializer(serializers.ModelSerializer):
"detail", "detail",
"creation_date", "creation_date",
"fetch_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
...@@ -5,7 +5,10 @@ from . import api_views ...@@ -5,7 +5,10 @@ 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"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 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
...@@ -28,8 +33,14 @@ def update_follow(follow, approved): ...@@ -28,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,
...@@ -49,9 +60,17 @@ class LibraryFollowViewSet( ...@@ -49,9 +60,17 @@ 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) 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)
...@@ -69,6 +88,10 @@ class LibraryFollowViewSet( ...@@ -69,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:
...@@ -80,6 +103,7 @@ class LibraryFollowViewSet( ...@@ -80,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:
...@@ -92,6 +116,27 @@ class LibraryFollowViewSet( ...@@ -92,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"
...@@ -146,12 +191,12 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -146,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)
...@@ -164,7 +209,6 @@ class InboxItemViewSet( ...@@ -164,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")
...@@ -192,8 +236,187 @@ class InboxItemViewSet( ...@@ -192,8 +236,187 @@ class InboxItemViewSet(
return response.Response(result, status=200) return response.Response(result, status=200)
class FetchViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): class FetchViewSet(
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]
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 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__)
...@@ -46,15 +48,20 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -46,15 +48,20 @@ class SignatureAuthentication(authentication.BaseAuthentication):
domain = urllib.parse.urlparse(actor_url).hostname domain = urllib.parse.urlparse(actor_url).hostname
allowed = models.Domain.objects.filter(name=domain, allowed=True).exists() allowed = models.Domain.objects.filter(name=domain, allowed=True).exists()
if not allowed: if not allowed:
logger.debug("Actor domain %s is not on allow-list", domain)
raise exceptions.BlockedActorOrDomain() 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")
...@@ -74,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -74,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",
...@@ -218,6 +220,12 @@ CONTEXTS = [ ...@@ -218,6 +220,12 @@ CONTEXTS = [
} }
}, },
}, },
{
"shortId": "SC",
"contextUrl": None,
"documentUrl": "http://schema.org",
"document": {"@context": schema_org.CONTEXT},
},
{ {
"shortId": "SEC", "shortId": "SEC",
"contextUrl": None, "contextUrl": None,
...@@ -280,11 +288,15 @@ CONTEXTS = [ ...@@ -280,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"},
...@@ -293,11 +305,57 @@ CONTEXTS = [ ...@@ -293,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"},
} }
}, },
}, },
...@@ -317,14 +375,14 @@ class NS: ...@@ -317,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()
...@@ -332,3 +390,5 @@ AS = NS(CONTEXTS_BY_ID["AS"]) ...@@ -332,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 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)
)
)