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
  • 2451-delete-no-user-query
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2471-display-empty-playlist-in-playlist-widget
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2492-only-deliver-to-reachable-domains
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2506-fix-frontend-regressions
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 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
  • 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
  • 278-search-browse
  • 303-json-ld
  • 316-ultrasonic
  • 334-don-t-display-an-empty-page-browser
  • 463-user-libraries
  • 463-user-libraries-full
  • ButterflyOfFire/funkwhale-patch-1
  • avatar-everywhere
  • develop
  • master
  • playlist-component
  • 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.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
49 results
Show changes
Showing
with 2628 additions and 463 deletions
import os
import youtube_dl
from django.conf import settings
def download(
url, target_directory=settings.MEDIA_ROOT, name="%(id)s.%(ext)s", bitrate=192
):
target_path = os.path.join(target_directory, name)
ydl_opts = {
"quiet": True,
"outtmpl": target_path,
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "vorbis"}],
}
_downloader = youtube_dl.YoutubeDL(ydl_opts)
info = _downloader.extract_info(url)
info["audio_file_path"] = target_path % {"id": info["id"], "ext": "ogg"}
return info
import random
import uuid
import factory import factory
import persisting_theory import persisting_theory
from django.conf import settings
from faker.providers import internet as internet_provider
class FactoriesRegistry(persisting_theory.Registry): class FactoriesRegistry(persisting_theory.Registry):
...@@ -28,3 +33,333 @@ def ManyToManyFromList(field_name): ...@@ -28,3 +33,333 @@ def ManyToManyFromList(field_name):
field.add(*extracted) field.add(*extracted)
return inner return inner
class NoUpdateOnCreate:
"""
Factory boy calls save after the initial create. In most case, this
is not needed, so we disable this behaviour
"""
@classmethod
def _after_postgeneration(cls, instance, create, results=None):
return
TAGS_DATA = {
"type": [
"acoustic",
"acid",
"ambient",
"alternative",
"brutalist",
"chill",
"club",
"cold",
"cool",
"contemporary",
"dark",
"doom",
"electro",
"folk",
"freestyle",
"fusion",
"garage",
"glitch",
"hard",
"healing",
"industrial",
"instrumental",
"hardcore",
"holiday",
"hot",
"liquid",
"modern",
"minimalist",
"new",
"parody",
"postmodern",
"progressive",
"smooth",
"symphonic",
"traditional",
"tribal",
"metal",
],
"genre": [
"blues",
"classical",
"chiptune",
"dance",
"disco",
"funk",
"jazz",
"house",
"hiphop",
"NewAge",
"pop",
"punk",
"rap",
"RnB",
"reggae",
"rock",
"soul",
"soundtrack",
"ska",
"swing",
"trance",
],
"nationality": [
"Afghan",
"Albanian",
"Algerian",
"American",
"Andorran",
"Angolan",
"Antiguans",
"Argentinean",
"Armenian",
"Australian",
"Austrian",
"Azerbaijani",
"Bahamian",
"Bahraini",
"Bangladeshi",
"Barbadian",
"Barbudans",
"Batswana",
"Belarusian",
"Belgian",
"Belizean",
"Beninese",
"Bhutanese",
"Bolivian",
"Bosnian",
"Brazilian",
"British",
"Bruneian",
"Bulgarian",
"Burkinabe",
"Burmese",
"Burundian",
"Cambodian",
"Cameroonian",
"Canadian",
"Cape Verdean",
"Central African",
"Chadian",
"Chilean",
"Chinese",
"Colombian",
"Comoran",
"Congolese",
"Costa Rican",
"Croatian",
"Cuban",
"Cypriot",
"Czech",
"Danish",
"Djibouti",
"Dominican",
"Dutch",
"East Timorese",
"Ecuadorean",
"Egyptian",
"Emirian",
"Equatorial Guinean",
"Eritrean",
"Estonian",
"Ethiopian",
"Fijian",
"Filipino",
"Finnish",
"French",
"Gabonese",
"Gambian",
"Georgian",
"German",
"Ghanaian",
"Greek",
"Grenadian",
"Guatemalan",
"Guinea-Bissauan",
"Guinean",
"Guyanese",
"Haitian",
"Herzegovinian",
"Honduran",
"Hungarian",
"I-Kiribati",
"Icelander",
"Indian",
"Indonesian",
"Iranian",
"Iraqi",
"Irish",
"Israeli",
"Italian",
"Ivorian",
"Jamaican",
"Japanese",
"Jordanian",
"Kazakhstani",
"Kenyan",
"Kittian and Nevisian",
"Kuwaiti",
"Kyrgyz",
"Laotian",
"Latvian",
"Lebanese",
"Liberian",
"Libyan",
"Liechtensteiner",
"Lithuanian",
"Luxembourger",
"Macedonian",
"Malagasy",
"Malawian",
"Malaysian",
"Maldivian",
"Malian",
"Maltese",
"Marshallese",
"Mauritanian",
"Mauritian",
"Mexican",
"Micronesian",
"Moldovan",
"Monacan",
"Mongolian",
"Moroccan",
"Mosotho",
"Motswana",
"Mozambican",
"Namibian",
"Nauruan",
"Nepalese",
"New Zealander",
"Ni-Vanuatu",
"Nicaraguan",
"Nigerian",
"Nigerien",
"North Korean",
"Northern Irish",
"Norwegian",
"Omani",
"Pakistani",
"Palauan",
"Panamanian",
"Papua New Guinean",
"Paraguayan",
"Peruvian",
"Polish",
"Portuguese",
"Qatari",
"Romanian",
"Russian",
"Rwandan",
"Saint Lucian",
"Salvadoran",
"Samoan",
"San Marinese",
"Sao Tomean",
"Saudi",
"Scottish",
"Senegalese",
"Serbian",
"Seychellois",
"Sierra Leonean",
"Singaporean",
"Slovakian",
"Slovenian",
"Solomon Islander",
"Somali",
"South African",
"South Korean",
"Spanish",
"Sri Lankan",
"Sudanese",
"Surinamer",
"Swazi",
"Swedish",
"Swiss",
"Syrian",
"Taiwanese",
"Tajik",
"Tanzanian",
"Thai",
"Togolese",
"Tongan",
"Trinidadian",
"Tunisian",
"Turkish",
"Tuvaluan",
"Ugandan",
"Ukrainian",
"Uruguayan",
"Uzbekistani",
"Venezuelan",
"Vietnamese",
"Welsh",
"Yemenite",
"Zambian",
"Zimbabwean",
],
}
class FunkwhaleProvider(internet_provider.Provider):
"""
Our own faker data generator, since built-in ones are sometimes
not random enough
"""
def federation_url(self, prefix="", obj_uuid=None, local=False):
if not obj_uuid:
obj_uuid = uuid.uuid4()
def path_generator():
return f"{prefix}/{obj_uuid}"
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https"
path = path_generator()
return f"{protocol}://{domain}/{path}"
def user_name(self):
u = super().user_name()
return f"{u}{random.randint(10, 999)}"
def music_genre(self):
return random.choice(TAGS_DATA["genre"])
def music_type(self):
return random.choice(TAGS_DATA["type"])
def music_nationality(self):
return random.choice(TAGS_DATA["nationality"])
def music_hashtag(self, prefix_length=4):
genre = self.music_genre()
prefixes = [genre]
nationality = False
while len(prefixes) < prefix_length:
if nationality:
t = "type"
else:
t = random.choice(["type", "nationality", "genre"])
if t == "nationality":
nationality = True
choice = random.choice(TAGS_DATA[t])
if choice in prefixes:
continue
prefixes.append(choice)
return "".join(
[p.capitalize().strip().replace(" ", "") for p in reversed(prefixes)]
)
factory.Faker.add_provider(FunkwhaleProvider)
...@@ -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(
......
from django.contrib import admin from funkwhale_api.common import admin
from . import models 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 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(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"])
from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import filters as common_filters
from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
class TrackFavoriteFilter(filters.FilterSet): 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="actor", distinct=True)
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["user", "q"] fields = []
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"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)
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models from . import models
...@@ -11,30 +10,40 @@ from . import models ...@@ -11,30 +10,40 @@ from . import models
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track") object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user") actor = federation_serializers.APIActorSerializer(read_only=True)
published = serializers.DateTimeField(source="creation_date") published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["id", "local_id", "object", "type", "actor", "published"] fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return "Like" return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True) track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date") fields = ("id", "actor", "track", "creation_date", "actor")
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "track", "creation_date") fields = ("id", "track", "creation_date")
class SimpleFavoriteSerializer(serializers.Serializer):
id = serializers.IntegerField()
track = serializers.IntegerField()
class AllFavoriteSerializer(serializers.Serializer):
results = SimpleFavoriteSerializer(many=True, source="*")
count = serializers.SerializerMethodField()
def get_count(self, o) -> int:
return len(o)
from rest_framework import routers from funkwhale_api.common import routers
from . import views from . import views
router = routers.SimpleRouter() router = routers.OptionalSlashRouter()
router.register(r"tracks", views.TrackFavoriteViewSet, "tracks") router.register(r"tracks", views.TrackFavoriteViewSet, "tracks")
urlpatterns = router.urls urlpatterns = router.urls
from django.db.models import Prefetch
from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.decorators import list_route from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from 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.federation import routes
from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers from . import filters, models, serializers
...@@ -16,54 +21,116 @@ class TrackFavoriteViewSet( ...@@ -16,54 +21,116 @@ class TrackFavoriteViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
filterset_class = filters.TrackFavoriteFilter
filter_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer serializer_class = serializers.UserTrackFavoriteSerializer
queryset = ( queryset = models.TrackFavorite.objects.all().select_related(
models.TrackFavorite.objects.all() "actor__attachment_icon"
.select_related("track__artist", "track__album__artist", "user")
.prefetch_related("track__files")
) )
permission_classes = [ permission_classes = [
permissions.ConditionalAuthentication, oauth_permissions.ScopePermission,
permissions.OwnerPermission, permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
] ]
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]: if self.request.method.lower() in ["head", "get", "options"]:
return serializers.UserTrackFavoriteSerializer return serializers.UserTrackFavoriteSerializer
return serializers.UserTrackFavoriteWriteSerializer return serializers.UserTrackFavoriteWriteSerializer
@extend_schema(operation_id="favorite_track")
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer) instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance) serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
plugins.trigger_hook(
plugins.FAVORITE_CREATED,
track_favorite=serializer.instance,
confs=plugins.get_confs(self.request.user),
)
record.send(instance) record.send(instance)
routes.outbox.dispatch(
{"type": "Like", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
) )
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
return 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(
music_utils.get_actor_from_request(self.request)
)
.prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
)
.select_related(
"attributed_to",
"album__attachment_cover",
)
) )
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
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
@list_route(methods=["delete", "post"]) @extend_schema(operation_id="unfavorite_track")
@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)
def all(self, request, *args, **kwargs):
"""
Return all the favorites of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
favorites status in the UI
"""
if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=401)
favorites = request.user.actor.track_favorites.values("id", "track").order_by(
"id"
)
payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200)
import logging
import urllib.parse
import uuid
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Q
from funkwhale_api.common import channels
from funkwhale_api.common import utils as funkwhale_utils
from . import contexts
recursive_getattr = funkwhale_utils.recursive_getattr
logger = logging.getLogger(__name__)
PUBLIC_ADDRESS = contexts.AS.Public
ACTIVITY_TYPES = [ ACTIVITY_TYPES = [
"Accept", "Accept",
"Add", "Add",
...@@ -29,33 +49,596 @@ ACTIVITY_TYPES = [ ...@@ -29,33 +49,596 @@ ACTIVITY_TYPES = [
"View", "View",
] ]
FUNKWHALE_OBJECT_TYPES = [
OBJECT_TYPES = [ ("Domain", "Domain"),
("Artist", "Artist"),
("Album", "Album"),
("Track", "Track"),
("Library", "Library"),
]
OBJECT_TYPES = (
[
"Application",
"Article", "Article",
"Audio", "Audio",
"Collection", "Collection",
"Document", "Document",
"Event", "Event",
"Group",
"Image", "Image",
"Note", "Note",
"Object",
"OrderedCollection", "OrderedCollection",
"Organization",
"Page", "Page",
"Person",
"Place", "Place",
"Profile", "Profile",
"Relationship", "Relationship",
"Service",
"Tombstone", "Tombstone",
"Video", "Video",
] + ACTIVITY_TYPES ]
+ ACTIVITY_TYPES
+ FUNKWHALE_OBJECT_TYPES
)
BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
def should_reject(fid, actor_id=None, payload={}):
if fid is None and actor_id is None:
return False
from funkwhale_api.moderation import models as moderation_models
policies = moderation_models.InstancePolicy.objects.active()
media_types = ["Audio", "Artist", "Album", "Track", "Library", "Image"]
relevant_values = [
recursive_getattr(payload, "type", permissive=True),
recursive_getattr(payload, "object.type", permissive=True),
recursive_getattr(payload, "target.type", permissive=True),
]
# if one of the payload types match our internal media types, then
# we apply policies that reject media
if set(media_types) & set(relevant_values):
policy_type = Q(block_all=True) | Q(reject_media=True)
else:
policy_type = Q(block_all=True)
if fid:
query = policies.matching_url_query(fid) & policy_type
if fid and actor_id:
query |= policies.matching_url_query(actor_id) & policy_type
elif actor_id:
query = policies.matching_url_query(actor_id) & policy_type
return policies.filter(query).exists()
@transaction.atomic
def receive(activity, on_behalf_of, inbox_actor=None):
"""
Receive an activity, find his recipients and save it to the database before dispatching it
"""
from funkwhale_api.moderation import mrf
from . import models, serializers, tasks
from .routes import inbox
logger.debug(
"[federation] Received activity from %s : %s", on_behalf_of.fid, activity
)
# we ensure the activity has the bare minimum structure before storing
# it in our database
serializer = serializers.BaseActivitySerializer(
data=activity,
context={
"actor": on_behalf_of,
"local_recipients": True,
"recipients": [inbox_actor] if inbox_actor else [],
},
)
serializer.is_valid(raise_exception=True)
payload, updated = mrf.inbox.apply(activity, sender_id=on_behalf_of.fid)
if not payload:
logger.info(
"[federation] Discarding activity due to mrf %s",
serializer.validated_data.get("id"),
)
return
if not inbox.get_matching_handlers(payload):
# discard unhandlable activity
logger.debug(
"[federation] No matching route found for activity, discarding: %s", payload
)
return
try:
copy = serializer.save(payload=payload, type=payload["type"])
except IntegrityError:
logger.warning(
"[federation] Discarding already delivered activity %s",
serializer.validated_data.get("id"),
)
return
local_to_recipients = get_actors_from_audience(
serializer.validated_data.get("to", [])
)
local_to_recipients = local_to_recipients.local()
local_to_recipients = local_to_recipients.values_list("pk", flat=True)
local_to_recipients = list(local_to_recipients)
if inbox_actor:
local_to_recipients.append(inbox_actor.pk)
local_cc_recipients = get_actors_from_audience(
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 = []
for recipients, type in [(local_to_recipients, "to"), (local_cc_recipients, "cc")]:
for r in recipients:
inbox_items.append(models.InboxItem(actor_id=r, type=type, activity=copy))
models.InboxItem.objects.bulk_create(inbox_items)
# at this point, we have the activity in database. Even if we crash, it's
# okay, as we can retry later
funkwhale_utils.on_commit(tasks.dispatch_inbox.delay, activity_id=copy.pk)
return copy
class Router:
def __init__(self):
self.routes = []
def connect(self, route, handler):
self.routes.append((route, handler))
def register(self, route):
def decorator(handler):
self.connect(route, handler)
return handler
return decorator
class InboxRouter(Router):
def get_matching_handlers(self, payload):
return [
handler for route, handler in self.routes if match_route(route, payload)
]
@transaction.atomic
def dispatch(self, payload, context, call_handlers=True):
"""
Receives an Activity payload and some context and trigger our
business logic.
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.
"""
from . import api_serializers, models
logger.debug(
f"[federation] Inbox dispatch payload : {payload} with context : {context}"
)
handlers = self.get_matching_handlers(payload)
for handler in handlers:
if call_handlers:
r = handler(payload, context=context)
else:
r = None
activity_obj = context.get("activity")
if activity_obj and r:
# handler returned additional data we can use
# to update the activity target
for key, value in r.items():
setattr(activity_obj, key, value)
update_fields = []
for k in r.keys():
if k in ["object", "target", "related_object"]:
update_fields += [
f"{k}_id",
f"{k}_content_type",
]
else:
update_fields.append(k)
activity_obj.save(update_fields=update_fields)
if payload["type"] not in BROADCAST_TO_USER_ACTIVITIES:
return
inbox_items = context.get("inbox_items", models.InboxItem.objects.none())
inbox_items = (
inbox_items.select_related()
.select_related("actor__user")
.prefetch_related(
"activity__object", "activity__target", "activity__related_object"
)
)
for ii in inbox_items:
user = ii.actor.get_user()
if not user:
continue
group = f"user.{user.pk}.inbox"
channels.group_send(
group,
{
"type": "event.send",
"text": "",
"data": {
"type": "inbox.item_added",
"item": api_serializers.InboxItemSerializer(ii).data,
},
},
)
return
ACTOR_KEY_ROTATION_LOCK_CACHE_KEY = "federation:actor-key-rotation-lock:{}"
def should_rotate_actor_key(actor_id):
lock = cache.get(ACTOR_KEY_ROTATION_LOCK_CACHE_KEY.format(actor_id))
return lock is None
def deliver(activity, on_behalf_of, to=[]): def schedule_key_rotation(actor_id, delay):
from . import tasks from . import tasks
return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to) cache.set(ACTOR_KEY_ROTATION_LOCK_CACHE_KEY.format(actor_id), True, timeout=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):
@transaction.atomic
def dispatch(self, routing, context):
"""
Receives a routing payload and some business objects in the context
and may yield data that should be persisted in the Activity model
for further delivery.
"""
from funkwhale_api.common import preferences
from . import models, tasks
logger.debug(
f"[federation] Outbox dispatch context : {context} and routing : {routing}"
)
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None
if allow_list_enabled:
allowed_domains = set(
models.Domain.objects.filter(allowed=True).values_list(
"name", flat=True
)
)
if activity_pass_user_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to user privacy_level"
)
return
if activity_pass_object_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to object privacy_level"
)
return
for route, handler in self.routes:
if not match_route(route, routing):
continue
activities_data = []
for e in handler(context):
# a route can yield zero, one or more activity payloads
if e:
activities_data.append(e)
deletions = [
a["actor"].id
for a in activities_data
if a["payload"]["type"] == "Delete"
]
for actor_id in deletions:
# we way need to triggers a blind key rotation
if should_rotate_actor_key(actor_id):
schedule_key_rotation(actor_id, settings.ACTOR_KEY_ROTATION_DELAY)
inbox_items_by_activity_uuid = {}
deliveries_by_activity_uuid = {}
prepared_activities = []
for activity_data in activities_data:
activity_data["payload"]["actor"] = activity_data["actor"].fid
to = activity_data["payload"].pop("to", [])
cc = activity_data["payload"].pop("cc", [])
a = models.Activity(**activity_data)
a.uuid = uuid.uuid4()
(
to_inbox_items,
to_deliveries,
new_to,
) = prepare_deliveries_and_inbox_items(
to, "to", allowed_domains=allowed_domains
)
(
cc_inbox_items,
cc_deliveries,
new_cc,
) = prepare_deliveries_and_inbox_items(
cc, "cc", allowed_domains=allowed_domains
)
if not any(
[to_inbox_items, to_deliveries, cc_inbox_items, cc_deliveries]
):
continue
deliveries_by_activity_uuid[str(a.uuid)] = to_deliveries + cc_deliveries
inbox_items_by_activity_uuid[str(a.uuid)] = (
to_inbox_items + cc_inbox_items
)
if new_to:
a.payload["to"] = new_to
if new_cc:
a.payload["cc"] = new_cc
prepared_activities.append(a)
activities = models.Activity.objects.bulk_create(prepared_activities)
for activity in activities:
if str(activity.uuid) in deliveries_by_activity_uuid:
for obj in deliveries_by_activity_uuid[str(a.uuid)]:
obj.activity = activity
if str(activity.uuid) in inbox_items_by_activity_uuid:
for obj in inbox_items_by_activity_uuid[str(a.uuid)]:
obj.activity = activity
# create all deliveries and items, in bulk
models.Delivery.objects.bulk_create(
[
obj
for collection in deliveries_by_activity_uuid.values()
for obj in collection
]
)
models.InboxItem.objects.bulk_create(
[
obj
for collection in inbox_items_by_activity_uuid.values()
for obj in collection
]
)
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)
return activities
def match_route(route, payload):
for key, value in route.items():
payload_value = recursive_getattr(payload, key, permissive=True)
if isinstance(value, list):
if payload_value not in value:
return False
elif payload_value != value:
return False
return True
def is_allowed_url(url, allowed_domains):
return (
allowed_domains is None
or urllib.parse.urlparse(url).hostname in allowed_domains
)
def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=None):
"""
Given a list of recipients (
either actor instances, public addresses, a dictionary with a "type" and "target"
keys for followers collections)
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.
"""
from . import models
if allowed_domains is not None:
allowed_domains = set(allowed_domains)
allowed_domains.add(settings.FEDERATION_HOSTNAME)
local_recipients = set()
remote_inbox_urls = set()
urls = []
for r in recipient_list:
if isinstance(r, models.Actor):
if r.is_local:
local_recipients.add(r)
else:
remote_inbox_urls.add(r.shared_inbox_url or r.inbox_url)
urls.append(r.fid)
elif r == PUBLIC_ADDRESS:
urls.append(r)
elif isinstance(r, dict) and r["type"] == "followers":
received_follows = (
r["target"]
.received_follows.filter(approved=True)
.select_related("actor__user")
)
for follow in received_follows:
actor = follow.actor
if actor.is_local:
local_recipients.add(actor)
else:
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_url)
elif isinstance(r, dict) and r["type"] == "actor_inbox":
actor = r["actor"]
urls.append(actor.fid)
if actor.is_local:
local_recipients.add(actor)
else:
remote_inbox_urls.add(actor.inbox_url)
elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors
# when we have at least one follower from this instance
follows = (
models.LibraryFollow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
.union(
models.Follow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
)
)
followed_domains = list(follows.values_list("actor__domain_id", flat=True))
actors = models.Actor.objects.filter(
managed_domains__name__in=followed_domains
)
values = actors.values("shared_inbox_url", "inbox_url", "domain_id")
handled_domains = set()
for v in values:
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
handled_domains.add(v["domain_id"])
if len(handled_domains) >= len(followed_domains):
continue
# for all remaining domains (probably non-funkwhale instances, with no
# service actors), we also pick the latest known actor per domain and send the message
# there instead
remaining_domains = models.Domain.objects.exclude(name__in=handled_domains)
remaining_domains = remaining_domains.filter(name__in=followed_domains)
actors = models.Actor.objects.filter(domain__in=remaining_domains)
actors = (
actors.order_by("domain_id", "-last_fetch_date")
.distinct("domain_id")
.values("shared_inbox_url", "inbox_url")
)
for v in actors:
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
deliveries = [
models.Delivery(inbox_url=url)
for url in remote_inbox_urls
if is_allowed_url(url, allowed_domains)
]
urls = [url for url in urls if is_allowed_url(url, allowed_domains)]
inbox_items = [
models.InboxItem(actor=actor, type=type) for actor in local_recipients
]
return inbox_items, deliveries, urls
def get_actors_from_audience(urls):
"""
Given a list of urls such as [
"https://hello.world/@bob/followers",
"https://eldritch.cafe/@alice/followers",
"https://funkwhale.demo/libraries/uuid/followers",
]
Returns a queryset of actors that are member of the collections
listed in the given urls. The urls may contain urls referring
to an actor, an actor followers collection or an library followers
collection.
Urls that don't match anything are simply discarded
"""
from . import models
def accept_follow(follow): queries = {"followed": None, "actors": []}
from . import serializers for url in urls:
if url == PUBLIC_ADDRESS:
continue
queries["actors"].append(url)
queries["followed"] = funkwhale_utils.join_queries_or(
queries["followed"], Q(target__followers_url=url)
)
final_query = None
if queries["actors"]:
final_query = funkwhale_utils.join_queries_or(
final_query, Q(fid__in=queries["actors"])
)
if queries["followed"]:
actor_follows = models.Follow.objects.filter(queries["followed"], approved=True)
final_query = funkwhale_utils.join_queries_or(
final_query, Q(pk__in=actor_follows.values_list("actor", flat=True))
)
serializer = serializers.AcceptFollowSerializer(follow) if not final_query:
return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target) return models.Actor.objects.none()
return models.Actor.objects.filter(final_query)
import datetime import datetime
import logging import logging
import xml
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import PermissionDenied
from funkwhale_api.common import preferences, session from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.users import models as users_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from . import activity, keys, models, serializers, signing, utils from . import keys, models, serializers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def remove_tags(text):
logger.debug("Removing tags from %s", text)
return "".join(
xml.etree.ElementTree.fromstring("<div>{}</div>".format(text)).itertext()
)
def get_actor_data(actor_url): def get_actor_data(actor_url):
logger.debug("Fetching actor %s", actor_url)
response = session.get_session().get( response = session.get_session().get(
actor_url, actor_url,
timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={"Accept": "application/activity+json"}, headers={"Accept": "application/activity+json"},
) )
response.raise_for_status() response.raise_for_status()
try: try:
return response.json() return response.json()
except Exception: except Exception:
raise ValueError("Invalid actor payload: {}".format(response.text)) raise ValueError(f"Invalid actor payload: {response.text}")
def get_actor(actor_url): def get_actor(fid, skip_cache=False):
if not skip_cache:
try: try:
actor = models.Actor.objects.get(url=actor_url) actor = models.Actor.objects.select_related().get(fid=fid)
except models.Actor.DoesNotExist: except models.Actor.DoesNotExist:
actor = None actor = None
fetch_delta = datetime.timedelta( fetch_delta = datetime.timedelta(
...@@ -50,330 +37,39 @@ def get_actor(actor_url): ...@@ -50,330 +37,39 @@ def get_actor(actor_url):
if actor and actor.last_fetch_date > timezone.now() - fetch_delta: if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
# cache is hot, we can return as is # cache is hot, we can return as is
return actor return actor
data = get_actor_data(actor_url) data = get_actor_data(fid)
serializer = serializers.ActorSerializer(data=data) serializer = serializers.ActorSerializer(data=data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.save(last_fetch_date=timezone.now()) return serializer.save(last_fetch_date=timezone.now())
class SystemActor(object): _CACHE = {}
additional_attributes = {}
manually_approves_followers = False
def get_request_auth(self):
actor = self.get_actor_instance()
return signing.get_auth(actor.private_key, actor.private_key_id)
def serialize(self): def get_service_actor(cache=True):
actor = self.get_actor_instance() if cache and "service_actor" in _CACHE:
serializer = serializers.ActorSerializer(actor) return _CACHE["service_actor"]
return serializer.data
def get_actor_instance(self): name, domain = (
settings.FEDERATION_SERVICE_ACTOR_USERNAME,
settings.FEDERATION_HOSTNAME,
)
try: try:
return models.Actor.objects.get(url=self.get_actor_url()) actor = models.Actor.objects.select_related().get(
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)
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
args = self.get_instance_argument(
self.id, name=self.name, summary=self.summary, **self.additional_attributes
)
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")
return models.Actor.objects.create(**args) args["type"] = "Service"
actor = models.Actor.objects.create(**args)
def get_actor_url(self): _CACHE["service_actor"] = actor
return utils.full_url( return actor
reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
)
def get_instance_argument(self, id, name, summary, **kwargs):
p = {
"preferred_username": id,
"domain": settings.FEDERATION_HOSTNAME,
"type": "Person",
"name": name.format(host=settings.FEDERATION_HOSTNAME),
"manually_approves_followers": True,
"url": self.get_actor_url(),
"shared_inbox_url": utils.full_url(
reverse("federation:instance-actors-inbox", kwargs={"actor": id})
),
"inbox_url": utils.full_url(
reverse("federation:instance-actors-inbox", kwargs={"actor": id})
),
"outbox_url": utils.full_url(
reverse("federation:instance-actors-outbox", kwargs={"actor": id})
),
"summary": summary.format(host=settings.FEDERATION_HOSTNAME),
}
p.update(kwargs)
return p
def get_inbox(self, data, actor=None):
raise NotImplementedError
def post_inbox(self, data, actor=None):
return self.handle(data, actor=actor)
def get_outbox(self, data, actor=None):
raise NotImplementedError
def post_outbox(self, data, actor=None):
raise NotImplementedError
def handle(self, data, actor=None):
"""
Main entrypoint for handling activities posted to the
actor's inbox
"""
logger.info("Received activity on %s inbox", self.id)
if actor is None:
raise PermissionDenied("Actor not authenticated")
serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
serializer.is_valid(raise_exception=True)
ac = serializer.data
try:
handler = getattr(self, "handle_{}".format(ac["type"].lower()))
except (KeyError, AttributeError):
logger.debug("No handler for activity %s", ac["type"])
return
return handler(data, actor)
def handle_follow(self, ac, sender):
serializer = serializers.FollowSerializer(
data=ac, context={"follow_actor": sender}
)
if not serializer.is_valid():
return logger.info("Invalid follow payload")
approved = True if not self.manually_approves_followers else None
follow = serializer.save(approved=approved)
if follow.approved:
return activity.accept_follow(follow)
def handle_accept(self, ac, sender):
system_actor = self.get_actor_instance()
serializer = serializers.AcceptFollowSerializer(
data=ac, context={"follow_target": sender, "follow_actor": system_actor}
)
if not serializer.is_valid(raise_exception=True):
return logger.info("Received invalid payload")
return serializer.save()
def handle_undo_follow(self, ac, sender):
system_actor = self.get_actor_instance()
serializer = serializers.UndoFollowSerializer(
data=ac, context={"actor": sender, "target": system_actor}
)
if not serializer.is_valid():
return logger.info("Received invalid payload")
serializer.save()
def handle_undo(self, ac, sender):
if ac["object"]["type"] != "Follow":
return
if ac["object"]["actor"] != sender.url:
# not the same actor, permission issue
return
self.handle_undo_follow(ac, sender)
class LibraryActor(SystemActor):
id = "library"
name = "{host}'s library"
summary = "Bot account to federate with {host}'s library"
additional_attributes = {"manually_approves_followers": True}
def serialize(self):
data = super().serialize()
urls = data.setdefault("url", [])
urls.append(
{
"type": "Link",
"mediaType": "application/activity+json",
"name": "library",
"href": utils.full_url(reverse("federation:music:files-list")),
}
)
return data
@property
def manually_approves_followers(self):
return preferences.get("federation__music_needs_approval")
@transaction.atomic
def handle_create(self, ac, sender):
try:
remote_library = models.Library.objects.get(
actor=sender, federation_enabled=True
)
except models.Library.DoesNotExist:
logger.info("Skipping import, we're not following %s", sender.url)
return
if ac["object"]["type"] != "Collection":
return
if ac["object"]["totalItems"] <= 0:
return
try:
items = ac["object"]["items"]
except KeyError:
logger.warning("No items in collection!")
return
item_serializers = [
serializers.AudioSerializer(data=i, context={"library": remote_library})
for i in items
]
now = timezone.now()
valid_serializers = []
for s in item_serializers:
if s.is_valid():
valid_serializers.append(s)
else:
logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors)
lts = []
for s in valid_serializers:
lts.append(s.save())
if remote_library.autoimport:
batch = music_models.ImportBatch.objects.create(source="federation")
for lt in lts:
if lt.creation_date < now:
# track was already in the library, we do not trigger
# an import
continue
job = music_models.ImportJob.objects.create(
batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
)
funkwhale_utils.on_commit(
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False,
)
class TestActor(SystemActor):
id = "test"
name = "{host}'s test account"
summary = (
"Bot account to test federation with {host}. "
"Send me /ping and I'll answer you."
)
additional_attributes = {"manually_approves_followers": False}
manually_approves_followers = False
def get_outbox(self, data, actor=None):
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
],
"id": utils.full_url(
reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": [],
}
def parse_command(self, message):
"""
Remove any links or fancy markup to extract /command from
a note message.
"""
raw = remove_tags(message)
try:
return raw.split("/")[1]
except IndexError:
return
def handle_create(self, ac, sender):
if ac["object"]["type"] != "Note":
return
# we received a toot \o/
command = self.parse_command(ac["object"]["content"])
logger.debug("Parsed command: %s", command)
if command != "ping":
return
now = timezone.now()
test_actor = self.get_actor_instance()
reply_url = "https://{}/activities/note/{}".format(
settings.FEDERATION_HOSTNAME, now.timestamp()
)
reply_activity = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
],
"type": "Create",
"actor": test_actor.url,
"id": "{}/activity".format(reply_url),
"published": now.isoformat(),
"to": ac["actor"],
"cc": [],
"object": {
"type": "Note",
"content": "Pong!",
"summary": None,
"published": now.isoformat(),
"id": reply_url,
"inReplyTo": ac["object"]["id"],
"sensitive": False,
"url": reply_url,
"to": [ac["actor"]],
"attributedTo": test_actor.url,
"cc": [],
"attachment": [],
"tag": [
{
"type": "Mention",
"href": ac["actor"],
"name": sender.mention_username,
}
],
},
}
activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
def handle_follow(self, ac, sender):
super().handle_follow(ac, sender)
# also, we follow back
test_actor = self.get_actor_instance()
follow_back = models.Follow.objects.get_or_create(
actor=test_actor, target=sender, approved=None
)[0]
activity.deliver(
serializers.FollowSerializer(follow_back).data,
to=[follow_back.target.url],
on_behalf_of=follow_back.actor,
)
def handle_undo_follow(self, ac, sender):
super().handle_undo_follow(ac, sender)
actor = self.get_actor_instance()
# we also unfollow the sender, if possible
try:
follow = models.Follow.objects.get(target=sender, actor=actor)
except models.Follow.DoesNotExist:
return
undo = serializers.UndoFollowSerializer(follow).data
follow.delete()
activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}
from django.contrib import admin from funkwhale_api.common import admin
from . import models from . import models, tasks
def redeliver_deliveries(modeladmin, request, queryset):
queryset.update(is_delivered=False)
for delivery in queryset:
tasks.deliver_to_remote.delay(delivery_id=delivery.pk)
redeliver_deliveries.short_description = "Redeliver"
def redeliver_activities(modeladmin, request, queryset):
for activity in queryset.select_related("actor__user"):
if activity.actor.get_user():
tasks.dispatch_outbox.delay(activity_id=activity.pk)
else:
tasks.dispatch_inbox.delay(activity_id=activity.pk)
redeliver_activities.short_description = "Redeliver"
@admin.register(models.Domain)
class DomainAdmin(admin.ModelAdmin):
list_display = ["name", "allowed", "creation_date"]
list_filter = ["allowed"]
search_fields = ["name"]
@admin.register(models.Fetch)
class FetchAdmin(admin.ModelAdmin):
list_display = ["url", "actor", "status", "creation_date", "fetch_date", "detail"]
search_fields = ["url", "actor__username"]
list_filter = ["status"]
list_select_related = True
@admin.register(models.Activity)
class ActivityAdmin(admin.ModelAdmin):
list_display = ["uuid", "type", "fid", "url", "actor", "creation_date"]
search_fields = ["payload", "fid", "url", "actor__domain__name"]
list_filter = ["type", "actor__domain__name"]
actions = [redeliver_activities]
list_select_related = True
@admin.register(models.Actor) @admin.register(models.Actor)
class ActorAdmin(admin.ModelAdmin): class ActorAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"url", "fid",
"domain", "domain",
"preferred_username", "preferred_username",
"type", "type",
"creation_date", "creation_date",
"last_fetch_date", "last_fetch_date",
] ]
search_fields = ["url", "domain", "preferred_username"] search_fields = ["fid", "domain__name", "preferred_username"]
list_filter = ["type"] list_filter = ["type"]
...@@ -21,28 +65,36 @@ class ActorAdmin(admin.ModelAdmin): ...@@ -21,28 +65,36 @@ class ActorAdmin(admin.ModelAdmin):
class FollowAdmin(admin.ModelAdmin): class FollowAdmin(admin.ModelAdmin):
list_display = ["actor", "target", "approved", "creation_date"] list_display = ["actor", "target", "approved", "creation_date"]
list_filter = ["approved"] list_filter = ["approved"]
search_fields = ["actor__url", "target__url"] search_fields = ["actor__fid", "target__fid"]
list_select_related = True list_select_related = True
@admin.register(models.Library) @admin.register(models.LibraryFollow)
class LibraryAdmin(admin.ModelAdmin): class LibraryFollowAdmin(admin.ModelAdmin):
list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] list_display = ["actor", "target", "approved", "creation_date"]
search_fields = ["actor__url", "url"] list_filter = ["approved"]
list_filter = ["federation_enabled", "download_files", "autoimport"] search_fields = ["actor__fid", "target__fid"]
list_select_related = True list_select_related = True
@admin.register(models.LibraryTrack) @admin.register(models.InboxItem)
class LibraryTrackAdmin(admin.ModelAdmin): class InboxItemAdmin(admin.ModelAdmin):
list_display = ["actor", "activity", "type", "is_read"]
list_filter = ["type", "activity__type", "is_read"]
search_fields = ["actor__fid", "activity__fid"]
list_select_related = True
@admin.register(models.Delivery)
class DeliveryAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"title", "inbox_url",
"artist_name", "activity",
"album_title", "last_attempt_date",
"url", "attempts",
"library", "is_delivered",
"creation_date",
"published_date",
] ]
search_fields = ["library__url", "url", "artist_name", "title", "album_title"] list_filter = ["activity__type", "is_delivered"]
search_fields = ["inbox_url"]
list_select_related = True list_select_related = True
actions = [redeliver_deliveries]
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 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.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, models
from . import serializers as federation_serializers
class NestedLibraryFollowSerializer(serializers.ModelSerializer):
class Meta:
model = models.LibraryFollow
fields = ["creation_date", "uuid", "fid", "approved", "modification_date"]
class LibraryScanSerializer(serializers.ModelSerializer):
class Meta:
model = music_models.LibraryScan
fields = [
"total_files",
"processed_files",
"errored_files",
"status",
"creation_date",
"modification_date",
]
class DomainSerializer(serializers.Serializer):
name = serializers.CharField()
class LibrarySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
uploads_count = serializers.SerializerMethodField()
latest_scan = LibraryScanSerializer(required=False, allow_null=True)
# The follow field is likely broken, so I removed the test
follow = NestedLibraryFollowSerializer(required=False, allow_null=True)
class Meta:
model = music_models.Library
fields = [
"fid",
"uuid",
"actor",
"name",
"creation_date",
"uploads_count",
"privacy_level",
"follow",
"latest_scan",
]
def get_uploads_count(self, o) -> int:
return max(getattr(o, "_uploads_count", 0), o.uploads_count)
@extend_schema_field(NestedLibraryFollowSerializer)
def get_follow(self, o):
try:
return NestedLibraryFollowSerializer(o._follows[0]).data
except (AttributeError, IndexError):
return None
class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
actor = serializers.SerializerMethodField()
class Meta:
model = models.LibraryFollow
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
actor = self.context["actor"]
if v.actor == actor:
raise serializers.ValidationError("You cannot follow your own library")
if v.received_follows.filter(actor=actor).exists():
raise serializers.ValidationError("You are already following this library")
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):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj):
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":
data["name"] = obj.name
if (
data["type"] == "federation.LibraryFollow"
or data["type"] == "federation.Follow"
):
data["approved"] = obj.approved
return data
class ActivitySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
object = serializers.SerializerMethodField(allow_null=True)
target = serializers.SerializerMethodField(allow_null=True)
related_object = serializers.SerializerMethodField()
class Meta:
model = models.Activity
fields = [
"uuid",
"fid",
"actor",
"payload",
"object",
"target",
"related_object",
"actor",
"creation_date",
"type",
]
@extend_schema_field(OpenApiTypes.OBJECT, None)
def get_object(self, o):
if o.object:
return serialize_generic_relation(o, o.object)
@extend_schema_field(OpenApiTypes.OBJECT)
def get_related_object(self, o):
if o.related_object:
return serialize_generic_relation(o, o.related_object)
@extend_schema_field(OpenApiTypes.OBJECT)
def get_target(self, o):
if o.target:
return serialize_generic_relation(o, o.target)
class InboxItemSerializer(serializers.ModelSerializer):
activity = ActivitySerializer()
class Meta:
model = models.InboxItem
fields = ["id", "type", "activity", "is_read"]
read_only_fields = ["id", "type", "activity"]
class InboxItemActionSerializer(common_serializers.ActionSerializer):
actions = [common_serializers.Action("read", allow_all=True)]
filterset_class = filters.InboxItemFilter
def handle_read(self, objects):
return objects.update(is_read=True)
OBJECT_SERIALIZER_MAPPING = {
music_models.Artist: federation_serializers.ArtistSerializer,
music_models.Album: federation_serializers.AlbumSerializer,
music_models.Track: federation_serializers.TrackSerializer,
models.Actor: federation_serializers.APIActorSerializer,
audio_models.Channel: audio_serializers.ChannelSerializer,
playlists_models.Playlist: federation_serializers.PlaylistSerializer,
}
def convert_url_to_webfinger(url):
parsed_url = urlparse(url)
domain = parsed_url.netloc # e.g., "node1.funkwhale.test"
path_parts = parsed_url.path.strip("/").split("/")
# Ensure the path is in the expected format
if len(path_parts) > 0 and path_parts[0].startswith("@"):
username = path_parts[0][1:] # Remove the '@'
return f"{username}@{domain}"
return None
class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True)
object_uri = serializers.CharField(required=True, write_only=True)
object = serializers.SerializerMethodField(read_only=True)
type = serializers.SerializerMethodField(read_only=True)
force = serializers.BooleanField(default=False, required=False, write_only=True)
class Meta:
model = models.Fetch
fields = [
"id",
"url",
"actor",
"status",
"detail",
"creation_date",
"fetch_date",
"object_uri",
"force",
"type",
"object",
]
read_only_fields = [
"id",
"url",
"actor",
"status",
"detail",
"creation_date",
"fetch_date",
"type",
"object",
]
def get_type(self, fetch):
obj = fetch.object
if obj is None:
return None
# Return the type as a string
if isinstance(obj, music_models.Artist):
return "artist"
elif isinstance(obj, music_models.Album):
return "album"
elif isinstance(obj, music_models.Track):
return "track"
elif isinstance(obj, models.Actor):
return "account"
elif isinstance(obj, audio_models.Channel):
return "channel"
elif isinstance(obj, playlists_models.Playlist):
return "playlist"
else:
return None
def validate_object_uri(self, value):
if value.startswith("https://"):
converted = convert_url_to_webfinger(value)
if converted:
value = converted
if value.startswith("@"):
value = value.lstrip("@")
validator = validators.EmailValidator()
try:
validator(value)
except validators.ValidationError:
return value
return f"webfinger://{value}"
@extend_schema_field(
{
"oneOf": [
{"$ref": "#/components/schemas/Artist"},
{"$ref": "#/components/schemas/Album"},
{"$ref": "#/components/schemas/Track"},
{"$ref": "#/components/schemas/APIActor"},
{"$ref": "#/components/schemas/Channel"},
{"$ref": "#/components/schemas/Playlist"},
]
}
)
def get_object(self, fetch):
obj = fetch.object
if obj is None:
return None
serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj))
if serializer_class:
return serializer_class(obj).data
return None
def create(self, validated_data):
check_duplicates = not validated_data.get("force", False)
if check_duplicates:
# first we check for duplicates
duplicate = (
validated_data["actor"]
.fetches.filter(
status="finished",
url=validated_data["object_uri"],
creation_date__gte=timezone.now()
- datetime.timedelta(
seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY
),
)
.order_by("-creation_date")
.first()
)
if duplicate:
return duplicate
fetch = models.Fetch.objects.create(
actor=validated_data["actor"], url=validated_data["object_uri"]
)
return fetch
class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField()
url = serializers.URLField()
domain = serializers.CharField(source="domain_id")
creation_date = serializers.DateTimeField()
last_fetch_date = serializers.DateTimeField()
name = serializers.CharField()
preferred_username = serializers.CharField()
full_username = serializers.CharField()
type = serializers.CharField()
is_local = serializers.BooleanField()
is_channel = serializers.SerializerMethodField()
manually_approves_followers = serializers.BooleanField()
user = users_serializers.UserBasicSerializer()
summary = common_serializers.ContentSerializer(source="summary_obj")
icon = common_serializers.AttachmentSerializer(source="attachment_icon")
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_channel(self, o):
try:
return bool(o.channel)
except ObjectDoesNotExist:
return False
from rest_framework import routers from funkwhale_api.common import routers
from . import views from . import api_views
router = routers.SimpleRouter() router = routers.OptionalSlashRouter()
router.register(r"libraries", views.LibraryViewSet, "libraries") router.register(r"fetches", api_views.FetchViewSet, "fetches")
router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks") 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"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
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Q
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import decorators, mixins, permissions, response, viewsets
from rest_framework.exceptions import NotFound as RestNotFound
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 serializers as music_serializers
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import (
activity,
api_serializers,
exceptions,
filters,
models,
routes,
serializers,
tasks,
utils,
)
@transaction.atomic
def update_follow(follow, approved):
follow.approved = approved
follow.save(update_fields=["approved"])
if approved:
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(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.LibraryFollow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target__actor")
)
serializer_class = api_serializers.LibraryFollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
filterset_class = filters.LibraryFollowFilter
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):
qs = super().get_queryset()
return qs.filter(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_library_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_library_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.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__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):
lookup_field = "uuid"
queryset = (
music_models.Library.objects.all()
.order_by("-creation_date")
.select_related("actor__user")
.annotate(_uploads_count=Count("uploads"))
)
serializer_class = api_serializers.LibrarySerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
def get_queryset(self):
qs = super().get_queryset()
return qs.viewable_by(actor=self.request.user.actor)
@decorators.action(methods=["post"], detail=True)
def scan(self, request, *args, **kwargs):
library = self.get_object()
if library.actor.get_user():
return response.Response({"status": "skipped"}, 200)
scan = library.schedule_scan(actor=request.user.actor)
if scan:
return response.Response(
{
"status": "scheduled",
"scan": api_serializers.LibraryScanSerializer(scan).data,
},
200,
)
return response.Response({"status": "skipped"}, 200)
@decorators.action(methods=["post"], detail=False)
def fetch(self, request, *args, **kwargs):
try:
fid = request.data["fid"]
except KeyError:
return response.Response({"fid": ["This field is required"]})
try:
library = utils.retrieve_ap_object(
fid,
actor=request.user.actor,
queryset=self.queryset,
serializer_class=serializers.LibrarySerializer,
)
except exceptions.BlockedActorOrDomain:
return response.Response(
{"detail": "This domain/account is blocked on your instance."},
status=400,
)
except requests.exceptions.RequestException as e:
return response.Response(
{"detail": f"Error while fetching the library: {str(e)}"},
status=400,
)
except serializers.serializers.ValidationError as e:
return response.Response(
{"detail": f"Invalid data in remote library: {str(e)}"},
status=400,
)
serializer = self.serializer_class(library)
return response.Response({"count": 1, "results": [serializer.data]})
class InboxItemViewSet(
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
queryset = (
models.InboxItem.objects.select_related("activity__actor")
.prefetch_related("activity__object", "activity__target")
.filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to")
.order_by("-activity__creation_date")
)
serializer_class = api_serializers.InboxItemSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "notifications"
filterset_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(actor=self.request.user.actor)
@decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = api_serializers.InboxItemActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class FetchViewSet(
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
queryset = models.Fetch.objects.select_related("actor")
serializer_class = api_serializers.FetchSerializer
permission_classes = [permissions.IsAuthenticated]
throttling_scopes = {"create": {"authenticated": "fetch"}}
def get_queryset(self):
return super().get_queryset().filter(actor=self.request.user.actor)
def perform_create(self, serializer):
fetch = serializer.save(actor=self.request.user.actor)
if fetch.status == "finished":
# a duplicate was returned, no need to fetch again
return
if settings.FEDERATION_SYNCHRONOUS_FETCH:
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
else:
common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk)
class DomainViewSet(
mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
):
queryset = models.Domain.objects.order_by("name").external()
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.DomainSerializer
ordering_fields = ("creation_date", "name")
max_page_size = 100
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
instance_policy__is_active=True, instance_policy__block_all=True
)
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(allowed=True)
return qs
class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = models.Actor.objects.select_related(
"user", "channel", "summary_obj", "attachment_icon"
)
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.FullActorSerializer
lookup_field = "full_username"
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
def get_object(self):
queryset = self.get_queryset()
username, domain = self.kwargs["full_username"].split("@", 1)
try:
return queryset.get(preferred_username=username, domain_id=domain)
except models.Actor.DoesNotExist:
raise RestNotFound(
detail=f"Actor {username}@{domain} not found",
)
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
domain__instance_policy__is_active=True,
domain__instance_policy__block_all=True,
)
if preferences.get("moderation__allow_list_enabled"):
query = Q(domain_id=settings.FUNKWHALE_HOSTNAME) | Q(domain__allowed=True)
qs = qs.filter(query)
return qs
libraries = decorators.action(
methods=["get"],
detail=True,
serializer_class=music_serializers.LibraryForOwnerSerializer,
)(
music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)
@extend_schema_view(
list=extend_schema(operation_id="get_federation_received_follows"),
create=extend_schema(operation_id="create_federation_user_follow"),
)
class UserFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.Follow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target")
.filter(actor__type="Person")
)
serializer_class = api_serializers.FollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_user_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_user_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(
Q(target=self.request.user.actor) | Q(actor=self.request.user.actor)
).exclude(approved=False)
def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor)
routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance}
)
instance.delete()
def get_serializer_context(self):
context = super().get_serializer_context()
context["actor"] = self.request.user.actor
return context
@extend_schema(
operation_id="accept_federation_user_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_user_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__fid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "actor": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)
import datetime
import logging
import urllib.parse
import cryptography import cryptography
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from rest_framework import authentication, exceptions from django.utils import timezone
from rest_framework import authentication
from rest_framework import exceptions as rest_exceptions
from funkwhale_api.common import preferences
from funkwhale_api.moderation import models as moderation_models
from . import actors, keys, signing, utils from . import actors, exceptions, keys, models, signing, tasks, utils
logger = logging.getLogger(__name__)
class SignatureAuthentication(authentication.BaseAuthentication): class SignatureAuthentication(authentication.BaseAuthentication):
...@@ -14,21 +25,68 @@ class SignatureAuthentication(authentication.BaseAuthentication): ...@@ -14,21 +25,68 @@ class SignatureAuthentication(authentication.BaseAuthentication):
except KeyError: except KeyError:
return return
except ValueError as e: except ValueError as e:
raise exceptions.AuthenticationFailed(str(e)) raise rest_exceptions.AuthenticationFailed(str(e))
try:
actor_url = key_id.split("#")[0]
except (TypeError, IndexError, AttributeError):
raise rest_exceptions.AuthenticationFailed("Invalid key id")
policies = (
moderation_models.InstancePolicy.objects.active()
.filter(block_all=True)
.matching_url(actor_url)
)
if policies.exists():
raise exceptions.BlockedActorOrDomain()
if request.method.lower() == "get" and preferences.get(
"moderation__allow_list_enabled"
):
# Only GET requests because POST requests with messages will be handled through
# MRF
domain = urllib.parse.urlparse(actor_url).hostname
allowed = models.Domain.objects.filter(name=domain, allowed=True).exists()
if not allowed:
logger.debug("Actor domain %s is not on allow-list", domain)
raise exceptions.BlockedActorOrDomain()
try: try:
actor = actors.get_actor(key_id.split("#")[0]) actor = actors.get_actor(actor_url)
except Exception as e: except Exception as e:
raise exceptions.AuthenticationFailed(str(e)) logger.info(
"Discarding HTTP request from actor/domain %s, %s",
actor_url,
str(e),
)
raise rest_exceptions.AuthenticationFailed(
"Cannot fetch remote actor to authenticate signature"
)
if not actor.public_key: if not actor.public_key:
raise exceptions.AuthenticationFailed("No public key found") raise rest_exceptions.AuthenticationFailed("No public key found")
try: try:
signing.verify_django(request, actor.public_key.encode("utf-8")) signing.verify_django(request, actor.public_key.encode("utf-8"))
except cryptography.exceptions.InvalidSignature: except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed("Invalid signature") # in case of invalid signature, we refetch the actor object
# to load a potentially new public key. This process is called
# Blind key rotation, and is described at
# https://blog.dereferenced.org/the-case-for-blind-key-rotation
# if signature verification fails after that, then we return a 403 error
actor = actors.get_actor(actor_url, skip_cache=True)
signing.verify_django(request, actor.public_key.encode("utf-8"))
# we trigger a nodeinfo update on the actor's domain, if needed
fetch_delay = 24 * 3600
now = timezone.now()
last_fetch = actor.domain.nodeinfo_fetch_date
if not actor.domain.is_local:
if not last_fetch or (
last_fetch < (now - datetime.timedelta(seconds=fetch_delay))
):
tasks.update_domain_nodeinfo(domain_name=actor.domain.name)
actor.domain.refresh_from_db()
return actor return actor
def authenticate(self, request): def authenticate(self, request):
......
from . import schema_org
CONTEXTS = [
{
"shortId": "LDP",
"contextUrl": None,
"documentUrl": "http://www.w3.org/ns/ldp",
"document": {
"@context": {
"ldp": "http://www.w3.org/ns/ldp#",
"id": "@id",
"type": "@type",
"Container": "ldp:Container",
"BasicContainer": "ldp:BasicContainer",
"DirectContainer": "ldp:DirectContainer",
"IndirectContainer": "ldp:IndirectContainer",
"hasMemberRelation": {"@id": "ldp:hasMemberRelation", "@type": "@id"},
"isMemberOfRelation": {"@id": "ldp:isMemberOfRelation", "@type": "@id"},
"membershipResource": {"@id": "ldp:membershipResource", "@type": "@id"},
"insertedContentRelation": {
"@id": "ldp:insertedContentRelation",
"@type": "@id",
},
"contains": {"@id": "ldp:contains", "@type": "@id"},
"member": {"@id": "ldp:member", "@type": "@id"},
"constrainedBy": {"@id": "ldp:constrainedBy", "@type": "@id"},
"Resource": "ldp:Resource",
"RDFSource": "ldp:RDFSource",
"NonRDFSource": "ldp:NonRDFSource",
"MemberSubject": "ldp:MemberSubject",
"PreferContainment": "ldp:PreferContainment",
"PreferMembership": "ldp:PreferMembership",
"PreferMinimalContainer": "ldp:PreferMinimalContainer",
"PageSortCriterion": "ldp:PageSortCriterion",
"pageSortCriteria": {
"@id": "ldp:pageSortCriteria",
"@type": "@id",
"@container": "@list",
},
"pageSortPredicate": {"@id": "ldp:pageSortPredicate", "@type": "@id"},
"pageSortOrder": {"@id": "ldp:pageSortOrder", "@type": "@id"},
"pageSortCollation": {"@id": "ldp:pageSortCollation", "@type": "@id"},
"Ascending": "ldp:Ascending",
"Descending": "ldp:Descending",
"Page": "ldp:Page",
"pageSequence": {"@id": "ldp:pageSequence", "@type": "@id"},
"inbox": {"@id": "ldp:inbox", "@type": "@id"},
}
},
},
{
"shortId": "AS",
"contextUrl": None,
"documentUrl": "https://www.w3.org/ns/activitystreams",
"document": {
"@context": {
"@vocab": "_:",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"as": "https://www.w3.org/ns/activitystreams#",
"ldp": "http://www.w3.org/ns/ldp#",
"id": "@id",
"type": "@type",
"Accept": "as:Accept",
"Activity": "as:Activity",
"IntransitiveActivity": "as:IntransitiveActivity",
"Add": "as:Add",
"Announce": "as:Announce",
"Application": "as:Application",
"Arrive": "as:Arrive",
"Article": "as:Article",
"Audio": "as:Audio",
"Block": "as:Block",
"Collection": "as:Collection",
"CollectionPage": "as:CollectionPage",
"Relationship": "as:Relationship",
"Create": "as:Create",
"Delete": "as:Delete",
"Dislike": "as:Dislike",
"Document": "as:Document",
"Event": "as:Event",
"Follow": "as:Follow",
"Flag": "as:Flag",
"Group": "as:Group",
"Ignore": "as:Ignore",
"Image": "as:Image",
"Invite": "as:Invite",
"Join": "as:Join",
"Leave": "as:Leave",
"Like": "as:Like",
"Link": "as:Link",
"Mention": "as:Mention",
"Note": "as:Note",
"Object": "as:Object",
"Offer": "as:Offer",
"OrderedCollection": "as:OrderedCollection",
"OrderedCollectionPage": "as:OrderedCollectionPage",
"Organization": "as:Organization",
"Page": "as:Page",
"Person": "as:Person",
"Place": "as:Place",
"Profile": "as:Profile",
"Question": "as:Question",
"Reject": "as:Reject",
"Remove": "as:Remove",
"Service": "as:Service",
"TentativeAccept": "as:TentativeAccept",
"TentativeReject": "as:TentativeReject",
"Tombstone": "as:Tombstone",
"Undo": "as:Undo",
"Update": "as:Update",
"Video": "as:Video",
"View": "as:View",
"Listen": "as:Listen",
"Read": "as:Read",
"Move": "as:Move",
"Travel": "as:Travel",
"IsFollowing": "as:IsFollowing",
"IsFollowedBy": "as:IsFollowedBy",
"IsContact": "as:IsContact",
"IsMember": "as:IsMember",
"subject": {"@id": "as:subject", "@type": "@id"},
"relationship": {"@id": "as:relationship", "@type": "@id"},
"actor": {"@id": "as:actor", "@type": "@id"},
"attributedTo": {"@id": "as:attributedTo", "@type": "@id"},
"attachment": {"@id": "as:attachment", "@type": "@id"},
"bcc": {"@id": "as:bcc", "@type": "@id"},
"bto": {"@id": "as:bto", "@type": "@id"},
"cc": {"@id": "as:cc", "@type": "@id"},
"context": {"@id": "as:context", "@type": "@id"},
"current": {"@id": "as:current", "@type": "@id"},
"first": {"@id": "as:first", "@type": "@id"},
"generator": {"@id": "as:generator", "@type": "@id"},
"icon": {"@id": "as:icon", "@type": "@id"},
"image": {"@id": "as:image", "@type": "@id"},
"inReplyTo": {"@id": "as:inReplyTo", "@type": "@id"},
"items": {"@id": "as:items", "@type": "@id"},
"instrument": {"@id": "as:instrument", "@type": "@id"},
"orderedItems": {
"@id": "as:items",
"@type": "@id",
"@container": "@list",
},
"last": {"@id": "as:last", "@type": "@id"},
"location": {"@id": "as:location", "@type": "@id"},
"next": {"@id": "as:next", "@type": "@id"},
"object": {"@id": "as:object", "@type": "@id"},
"oneOf": {"@id": "as:oneOf", "@type": "@id"},
"anyOf": {"@id": "as:anyOf", "@type": "@id"},
"closed": {"@id": "as:closed", "@type": "xsd:dateTime"},
"origin": {"@id": "as:origin", "@type": "@id"},
"accuracy": {"@id": "as:accuracy", "@type": "xsd:float"},
"prev": {"@id": "as:prev", "@type": "@id"},
"preview": {"@id": "as:preview", "@type": "@id"},
"replies": {"@id": "as:replies", "@type": "@id"},
"result": {"@id": "as:result", "@type": "@id"},
"audience": {"@id": "as:audience", "@type": "@id"},
"partOf": {"@id": "as:partOf", "@type": "@id"},
"tag": {"@id": "as:tag", "@type": "@id"},
"target": {"@id": "as:target", "@type": "@id"},
"to": {"@id": "as:to", "@type": "@id"},
"url": {"@id": "as:url", "@type": "@id"},
"altitude": {"@id": "as:altitude", "@type": "xsd:float"},
"content": "as:content",
"contentMap": {"@id": "as:content", "@container": "@language"},
"name": "as:name",
"nameMap": {"@id": "as:name", "@container": "@language"},
"duration": {"@id": "as:duration", "@type": "xsd:duration"},
"endTime": {"@id": "as:endTime", "@type": "xsd:dateTime"},
"height": {"@id": "as:height", "@type": "xsd:nonNegativeInteger"},
"href": {"@id": "as:href", "@type": "@id"},
"hreflang": "as:hreflang",
"latitude": {"@id": "as:latitude", "@type": "xsd:float"},
"longitude": {"@id": "as:longitude", "@type": "xsd:float"},
"mediaType": "as:mediaType",
"published": {"@id": "as:published", "@type": "xsd:dateTime"},
"radius": {"@id": "as:radius", "@type": "xsd:float"},
"rel": "as:rel",
"startIndex": {
"@id": "as:startIndex",
"@type": "xsd:nonNegativeInteger",
},
"startTime": {"@id": "as:startTime", "@type": "xsd:dateTime"},
"summary": "as:summary",
"summaryMap": {"@id": "as:summary", "@container": "@language"},
"totalItems": {
"@id": "as:totalItems",
"@type": "xsd:nonNegativeInteger",
},
"units": "as:units",
"updated": {"@id": "as:updated", "@type": "xsd:dateTime"},
"width": {"@id": "as:width", "@type": "xsd:nonNegativeInteger"},
"describes": {"@id": "as:describes", "@type": "@id"},
"formerType": {"@id": "as:formerType", "@type": "@id"},
"deleted": {"@id": "as:deleted", "@type": "xsd:dateTime"},
"inbox": {"@id": "ldp:inbox", "@type": "@id"},
"outbox": {"@id": "as:outbox", "@type": "@id"},
"following": {"@id": "as:following", "@type": "@id"},
"followers": {"@id": "as:followers", "@type": "@id"},
"streams": {"@id": "as:streams", "@type": "@id"},
"preferredUsername": "as:preferredUsername",
"endpoints": {"@id": "as:endpoints", "@type": "@id"},
"uploadMedia": {"@id": "as:uploadMedia", "@type": "@id"},
"proxyUrl": {"@id": "as:proxyUrl", "@type": "@id"},
"liked": {"@id": "as:liked", "@type": "@id"},
"oauthAuthorizationEndpoint": {
"@id": "as:oauthAuthorizationEndpoint",
"@type": "@id",
},
"oauthTokenEndpoint": {"@id": "as:oauthTokenEndpoint", "@type": "@id"},
"provideClientKey": {"@id": "as:provideClientKey", "@type": "@id"},
"signClientKey": {"@id": "as:signClientKey", "@type": "@id"},
"sharedInbox": {"@id": "as:sharedInbox", "@type": "@id"},
"Public": {"@id": "as:Public", "@type": "@id"},
"source": "as:source",
"likes": {"@id": "as:likes", "@type": "@id"},
"shares": {"@id": "as:shares", "@type": "@id"},
# Added manually
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
}
},
},
{
"shortId": "SC",
"contextUrl": None,
"documentUrl": "http://schema.org",
"document": {"@context": schema_org.CONTEXT},
},
{
"shortId": "SEC",
"contextUrl": None,
"documentUrl": "https://w3id.org/security/v1",
"document": {
"@context": {
"id": "@id",
"type": "@type",
"dc": "http://purl.org/dc/terms/",
"sec": "https://w3id.org/security#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016",
"Ed25519Signature2018": "sec:Ed25519Signature2018",
"EncryptedMessage": "sec:EncryptedMessage",
"GraphSignature2012": "sec:GraphSignature2012",
"LinkedDataSignature2015": "sec:LinkedDataSignature2015",
"LinkedDataSignature2016": "sec:LinkedDataSignature2016",
"CryptographicKey": "sec:Key",
"authenticationTag": "sec:authenticationTag",
"canonicalizationAlgorithm": "sec:canonicalizationAlgorithm",
"cipherAlgorithm": "sec:cipherAlgorithm",
"cipherData": "sec:cipherData",
"cipherKey": "sec:cipherKey",
"created": {"@id": "dc:created", "@type": "xsd:dateTime"},
"creator": {"@id": "dc:creator", "@type": "@id"},
"digestAlgorithm": "sec:digestAlgorithm",
"digestValue": "sec:digestValue",
"domain": "sec:domain",
"encryptionKey": "sec:encryptionKey",
"expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
"expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
"initializationVector": "sec:initializationVector",
"iterationCount": "sec:iterationCount",
"nonce": "sec:nonce",
"normalizationAlgorithm": "sec:normalizationAlgorithm",
"owner": {"@id": "sec:owner", "@type": "@id"},
"password": "sec:password",
"privateKey": {"@id": "sec:privateKey", "@type": "@id"},
"privateKeyPem": "sec:privateKeyPem",
"publicKey": {"@id": "sec:publicKey", "@type": "@id"},
"publicKeyBase58": "sec:publicKeyBase58",
"publicKeyPem": "sec:publicKeyPem",
"publicKeyWif": "sec:publicKeyWif",
"publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"},
"revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"},
"salt": "sec:salt",
"signature": "sec:signature",
"signatureAlgorithm": "sec:signingAlgorithm",
"signatureValue": "sec:signatureValue",
}
},
},
{
"shortId": "FW",
"contextUrl": None,
"documentUrl": "https://funkwhale.audio/ns",
"document": {
"@context": {
"id": "@id",
"type": "@type",
"as": "https://www.w3.org/ns/activitystreams#",
"fw": "https://funkwhale.audio/ns#",
"schema": "http://schema.org#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"Album": "fw:Album",
"Track": "fw:Track",
"Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library",
"Playlist": "fw:Playlist",
"PlaylistTrack": "fw:PlaylistTrack",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
"disc": {"@id": "fw:disc", "@type": "xsd:nonNegativeInteger"},
"library": {"@id": "fw:library", "@type": "@id"},
"track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"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"},
"musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"},
"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"},
}
},
},
]
CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS}
class NS:
def __init__(self, conf):
self.conf = conf
self.baseUrl = self.conf["document"]["@context"][self.conf["shortId"].lower()]
def __repr__(self):
return "<{}: {}>".format(self.conf["shortId"], self.baseUrl)
def __getattr__(self, key):
if key not in self.conf["document"]["@context"]:
raise AttributeError(
f"{key} is not a valid property of context {self.baseUrl}"
)
return self.baseUrl + key
class NoopContext:
def __getattr__(self, key):
return f"_:{key}"
NOOP = NoopContext()
AS = NS(CONTEXTS_BY_ID["AS"])
LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"])
SC = NS(CONTEXTS_BY_ID["SC"])
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])