funkwhale/api/funkwhale_api/federation/models.py

656 wiersze
22 KiB
Python
Czysty Zwykły widok Historia

import tempfile
2019-04-19 10:05:13 +00:00
import urllib.parse
2018-06-10 08:55:16 +00:00
import uuid
2018-04-03 19:30:15 +00:00
2018-03-31 13:44:35 +00:00
from django.conf import settings
2018-09-13 15:18:23 +00:00
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
2018-04-11 21:13:33 +00:00
from django.core.serializers.json import DjangoJSONEncoder
2018-03-31 13:44:35 +00:00
from django.db import models
from django.db.models import JSONField
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
2018-03-31 13:44:35 +00:00
from funkwhale_api.common import session, fields
2018-07-13 12:10:39 +00:00
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import validators as common_validators
from funkwhale_api.music import utils as music_utils
from . import utils as federation_utils
2018-03-31 13:44:35 +00:00
TYPE_CHOICES = [
2018-06-09 13:36:16 +00:00
("Person", "Person"),
("Tombstone", "Tombstone"),
2018-06-09 13:36:16 +00:00
("Application", "Application"),
("Group", "Group"),
("Organization", "Organization"),
("Service", "Service"),
2018-03-31 13:44:35 +00:00
]
MAX_LENGTHS = {
"ACTOR_NAME": 200,
}
2018-03-31 13:44:35 +00:00
def empty_dict():
return {}
def get_shared_inbox_url():
return federation_utils.full_url(reverse("federation:shared-inbox"))
class FederationMixin(models.Model):
# federation id/url
fid = models.URLField(unique=True, max_length=500, db_index=True)
url = models.URLField(max_length=500, null=True, blank=True)
class Meta:
abstract = True
2019-04-19 10:05:13 +00:00
@property
2022-07-20 12:31:57 +00:00
def is_local(self) -> bool:
2019-04-19 10:05:13 +00:00
return federation_utils.is_local(self.fid)
@property
def domain_name(self):
if not self.fid:
return
parsed = urllib.parse.urlparse(self.fid)
return parsed.hostname
2018-07-22 10:20:16 +00:00
class ActorQuerySet(models.QuerySet):
def local(self, include=True):
2019-01-30 10:54:43 +00:00
if include:
return self.filter(domain__name=settings.FEDERATION_HOSTNAME)
return self.exclude(domain__name=settings.FEDERATION_HOSTNAME)
2018-07-22 10:20:16 +00:00
def with_current_usage(self):
qs = self
for s in ["draft", "pending", "skipped", "errored", "finished"]:
uploads_query = models.Q(
libraries__uploads__import_status=s,
libraries__uploads__audio_file__isnull=False,
libraries__uploads__audio_file__ne="",
)
qs = qs.annotate(
**{
f"_usage_{s}": models.Sum(
"libraries__uploads__size", filter=uploads_query
)
}
)
return qs
2019-01-03 10:47:29 +00:00
def with_uploads_count(self):
return self.annotate(
uploads_count=models.Count("libraries__uploads", distinct=True)
)
2018-07-22 10:20:16 +00:00
class DomainQuerySet(models.QuerySet):
def external(self):
return self.exclude(pk=settings.FEDERATION_HOSTNAME)
def with_actors_count(self):
return self.annotate(actors_count=models.Count("actors", distinct=True))
def with_outbox_activities_count(self):
return self.annotate(
outbox_activities_count=models.Count(
"actors__outbox_activities", distinct=True
)
)
class Domain(models.Model):
name = models.CharField(
primary_key=True,
max_length=255,
validators=[common_validators.DomainValidator()],
)
creation_date = models.DateTimeField(default=timezone.now)
2018-12-27 16:42:43 +00:00
nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
2018-12-27 18:58:34 +00:00
nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
2019-01-30 10:54:43 +00:00
service_actor = models.ForeignKey(
"Actor",
related_name="managed_domains",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
2019-06-17 06:48:05 +00:00
# are interactions with this domain allowed (only applies when allow-listing is on)
allowed = models.BooleanField(default=None, null=True)
reachable = models.BooleanField(default=True)
last_successful_contact = models.DateTimeField(default=None, null=True)
objects = DomainQuerySet.as_manager()
def __str__(self):
return self.name
def save(self, **kwargs):
lowercase_fields = ["name"]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)
2018-12-27 18:58:34 +00:00
def get_stats(self):
from funkwhale_api.music import models as music_models
data = Domain.objects.filter(pk=self.pk).aggregate(
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
libraries=models.Count("actors__libraries", distinct=True),
channels=models.Count("actors__owned_channels", distinct=True),
2018-12-27 18:58:34 +00:00
received_library_follows=models.Count(
"actors__libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count(
"actors__library_follows", distinct=True
),
actors=models.Count("actors", distinct=True),
2018-12-27 18:58:34 +00:00
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
data["tracks"] = music_models.Track.objects.filter(
from_activity__actor__domain_id=self.pk
).count()
uploads = music_models.Upload.objects.filter(library__actor__domain_id=self.pk)
data["uploads"] = uploads.count()
2018-12-27 18:58:34 +00:00
data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
data["media_downloaded_size"] = (
uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
)
return data
@property
2022-07-20 12:31:57 +00:00
def is_local(self) -> bool:
return self.name == settings.FEDERATION_HOSTNAME
2018-03-31 13:44:35 +00:00
class Actor(models.Model):
2018-06-09 13:36:16 +00:00
ap_type = "Actor"
2018-04-03 21:25:44 +00:00
fid = models.URLField(unique=True, max_length=500, db_index=True)
url = models.URLField(max_length=500, null=True, blank=True)
2019-12-09 12:59:54 +00:00
outbox_url = models.URLField(max_length=500, null=True, blank=True)
inbox_url = models.URLField(max_length=500, null=True, blank=True)
2018-03-31 13:44:35 +00:00
following_url = models.URLField(max_length=500, null=True, blank=True)
followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
2018-06-09 13:36:16 +00:00
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
name = models.CharField(max_length=MAX_LENGTHS["ACTOR_NAME"], null=True, blank=True)
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
2018-03-31 13:44:35 +00:00
summary = models.CharField(max_length=500, null=True, blank=True)
2020-01-23 10:09:52 +00:00
summary_obj = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
2018-06-09 13:36:16 +00:00
preferred_username = models.CharField(max_length=200, null=True, blank=True)
2018-09-22 12:29:30 +00:00
public_key = models.TextField(max_length=5000, null=True, blank=True)
private_key = models.TextField(max_length=5000, null=True, blank=True)
2018-03-31 13:44:35 +00:00
creation_date = models.DateTimeField(default=timezone.now)
2018-06-09 13:36:16 +00:00
last_fetch_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = models.BooleanField(default=None, null=True)
2018-04-04 21:12:41 +00:00
followers = models.ManyToManyField(
2018-06-09 13:36:16 +00:00
to="self",
2018-04-04 21:12:41 +00:00
symmetrical=False,
2018-06-09 13:36:16 +00:00
through="Follow",
through_fields=("target", "actor"),
related_name="following",
2018-04-04 21:12:41 +00:00
)
2020-01-23 15:38:04 +00:00
attachment_icon = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
privacy_level = fields.get_privacy_field()
2018-03-31 13:44:35 +00:00
2018-07-22 10:20:16 +00:00
objects = ActorQuerySet.as_manager()
class Meta:
2018-06-09 13:36:16 +00:00
unique_together = ["domain", "preferred_username"]
verbose_name = "Account"
def get_moderation_url(self):
return f"/manage/moderation/accounts/{self.full_username}"
2018-03-31 13:44:35 +00:00
@property
def webfinger_subject(self):
return f"{self.preferred_username}@{settings.FEDERATION_HOSTNAME}"
@property
def private_key_id(self):
return f"{self.fid}#main-key"
2018-04-02 17:15:27 +00:00
@property
2022-07-20 12:31:57 +00:00
def full_username(self) -> str:
return f"{self.preferred_username}@{self.domain_id}"
def __str__(self):
return f"{self.preferred_username}@{self.domain_id}"
2018-04-02 17:15:27 +00:00
2018-04-04 21:12:41 +00:00
@property
2022-07-20 12:31:57 +00:00
def is_local(self) -> bool:
return self.domain_id == settings.FEDERATION_HOSTNAME
2018-04-03 19:30:15 +00:00
def get_approved_followers(self):
follows = self.received_follows.filter(approved=True)
2018-06-09 13:36:16 +00:00
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.actor.privacy_level == "public":
return True
return False
2018-04-03 19:30:15 +00:00
def get_user(self):
try:
return self.user
except ObjectDoesNotExist:
return None
def get_channel(self):
try:
return self.channel
except ObjectDoesNotExist:
return None
def get_absolute_url(self):
if self.is_local:
return federation_utils.full_url(f"/@{self.preferred_username}")
return self.url or self.fid
def get_current_usage(self):
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
data = {}
for s in ["draft", "pending", "skipped", "errored", "finished"]:
data[s] = getattr(actor, f"_usage_{s}") or 0
data["total"] = sum(data.values())
return data
2018-04-03 21:25:44 +00:00
def get_stats(self):
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
data = Actor.objects.filter(pk=self.pk).aggregate(
outbox_activities=models.Count("outbox_activities", distinct=True),
channels=models.Count("owned_channels", distinct=True),
received_library_follows=models.Count(
"libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count("library_follows", distinct=True),
libraries=models.Count("libraries", distinct=True),
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor=self.pk
).count()
data["reports"] = moderation_models.Report.objects.get_for_target(self).count()
2020-03-18 10:57:33 +00:00
data["requests"] = moderation_models.UserRequest.objects.filter(
submitter=self
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor=self.pk
).count()
data["tracks"] = music_models.Track.objects.filter(
from_activity__actor=self.pk
).count()
uploads = music_models.Upload.objects.filter(library__actor=self.pk)
data["uploads"] = uploads.count()
data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
data["media_downloaded_size"] = (
uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
)
return data
2019-01-04 13:36:08 +00:00
@property
def keys(self):
return self.private_key, self.public_key
@keys.setter
def keys(self, v):
self.private_key = v[0].decode("utf-8")
self.public_key = v[1].decode("utf-8")
2019-04-11 08:17:10 +00:00
def can_manage(self, obj):
attributed_to = getattr(obj, "attributed_to_id", None)
if attributed_to is not None and attributed_to == self.pk:
# easiest case, the obj is attributed to the actor
return True
if self.domain.service_actor_id != self.pk:
# actor is not system actor, so there is no way the actor can manage
# the object
return False
# actor is service actor of its domain, so if the fid domain
# matches, we consider the actor has the permission to manage
# the object
domain = self.domain_id
return obj.fid.startswith(f"http://{domain}/") or obj.fid.startswith(
f"https://{domain}/"
2019-04-11 08:17:10 +00:00
)
2020-01-30 16:28:52 +00:00
@property
def display_name(self):
return self.name or self.preferred_username
2019-04-18 12:37:17 +00:00
FETCH_STATUSES = [
("pending", "Pending"),
("errored", "Errored"),
("finished", "Finished"),
("skipped", "Skipped"),
]
class FetchQuerySet(models.QuerySet):
def get_for_object(self, object):
content_type = ContentType.objects.get_for_model(object)
return self.filter(object_content_type=content_type, object_id=object.pk)
class Fetch(models.Model):
url = models.URLField(max_length=500, db_index=True)
creation_date = models.DateTimeField(default=timezone.now)
fetch_date = models.DateTimeField(null=True, blank=True)
object_id = models.IntegerField(null=True)
object_content_type = models.ForeignKey(
ContentType, null=True, on_delete=models.CASCADE
)
object = GenericForeignKey("object_content_type", "object_id")
status = models.CharField(default="pending", choices=FETCH_STATUSES, max_length=20)
detail = JSONField(
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
)
2019-04-18 12:37:17 +00:00
actor = models.ForeignKey(Actor, related_name="fetches", on_delete=models.CASCADE)
objects = FetchQuerySet.as_manager()
def save(self, **kwargs):
2020-03-02 16:23:03 +00:00
if not self.url and self.object and hasattr(self.object, "fid"):
2019-04-18 12:37:17 +00:00
self.url = self.object.fid
super().save(**kwargs)
@property
def serializers(self):
from . import contexts, serializers
2019-04-18 12:37:17 +00:00
return {
contexts.FW.Artist: [serializers.ArtistSerializer],
contexts.FW.Album: [serializers.AlbumSerializer],
contexts.FW.Track: [serializers.TrackSerializer],
contexts.AS.Audio: [
serializers.UploadSerializer,
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],
contexts.AS.Service: [serializers.ActorSerializer],
contexts.AS.Application: [serializers.ActorSerializer],
2019-04-18 12:37:17 +00:00
}
class InboxItem(models.Model):
2018-09-22 12:29:30 +00:00
"""
Store activities binding to local actors, with read/unread status.
"""
actor = models.ForeignKey(
Actor, related_name="inbox_items", on_delete=models.CASCADE
)
activity = models.ForeignKey(
"Activity", related_name="inbox_items", on_delete=models.CASCADE
)
type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
2018-09-13 15:18:23 +00:00
is_read = models.BooleanField(default=False)
2018-09-22 12:29:30 +00:00
class Delivery(models.Model):
"""
Store deliveries attempt to remote inboxes
"""
is_delivered = models.BooleanField(default=False)
last_attempt_date = models.DateTimeField(null=True, blank=True)
attempts = models.PositiveIntegerField(default=0)
inbox_url = models.URLField(max_length=500)
activity = models.ForeignKey(
"Activity", related_name="deliveries", on_delete=models.CASCADE
)
class Activity(models.Model):
actor = models.ForeignKey(
Actor, related_name="outbox_activities", on_delete=models.CASCADE
)
recipients = models.ManyToManyField(
Actor, related_name="inbox_activities", through=InboxItem
)
2018-04-03 19:30:15 +00:00
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
url = models.URLField(max_length=500, null=True, blank=True)
payload = JSONField(
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
)
2018-09-13 15:18:23 +00:00
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
2020-03-11 10:39:55 +00:00
object_id = models.IntegerField(null=True, blank=True)
2018-09-13 15:18:23 +00:00
object_content_type = models.ForeignKey(
ContentType,
null=True,
2020-03-11 10:39:55 +00:00
blank=True,
2018-09-13 15:18:23 +00:00
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
2020-03-11 10:39:55 +00:00
target_id = models.IntegerField(null=True, blank=True)
2018-09-13 15:18:23 +00:00
target_content_type = models.ForeignKey(
ContentType,
null=True,
2020-03-11 10:39:55 +00:00
blank=True,
2018-09-13 15:18:23 +00:00
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
2020-03-11 10:39:55 +00:00
related_object_id = models.IntegerField(null=True, blank=True)
2018-09-13 15:18:23 +00:00
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
2020-03-11 10:39:55 +00:00
blank=True,
2018-09-13 15:18:23 +00:00
on_delete=models.SET_NULL,
related_name="related_objecting_activities",
)
related_object = GenericForeignKey(
"related_object_content_type", "related_object_id"
)
class AbstractFollow(models.Model):
ap_type = "Follow"
fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
approved = models.BooleanField(default=None, null=True)
class Meta:
abstract = True
def get_federation_id(self):
return federation_utils.full_url(f"{self.actor.fid}#follows/{self.uuid}")
class Follow(AbstractFollow):
2018-04-03 19:30:15 +00:00
actor = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Actor, related_name="emitted_follows", on_delete=models.CASCADE
2018-04-03 19:30:15 +00:00
)
target = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Actor, related_name="received_follows", on_delete=models.CASCADE
2018-04-03 19:30:15 +00:00
)
class Meta:
2018-06-09 13:36:16 +00:00
unique_together = ["actor", "target"]
2018-04-03 21:25:44 +00:00
class LibraryFollow(AbstractFollow):
actor = models.ForeignKey(
Actor, related_name="library_follows", on_delete=models.CASCADE
)
target = models.ForeignKey(
"music.Library", related_name="received_follows", on_delete=models.CASCADE
)
class Meta:
unique_together = ["actor", "target"]
2018-04-04 17:38:28 +00:00
class Library(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
2018-06-09 13:36:16 +00:00
modification_date = models.DateTimeField(auto_now=True)
fetched_date = models.DateTimeField(null=True, blank=True)
actor = models.OneToOneField(
2018-06-09 13:36:16 +00:00
Actor, on_delete=models.CASCADE, related_name="library"
)
uuid = models.UUIDField(default=uuid.uuid4)
url = models.URLField(max_length=500)
# use this flag to disable federation with a library
federation_enabled = models.BooleanField()
# should we mirror files locally or hotlink them?
download_files = models.BooleanField()
# should we automatically import new files from this library?
autoimport = models.BooleanField()
tracks_count = models.PositiveIntegerField(null=True, blank=True)
follow = models.OneToOneField(
2018-06-09 13:36:16 +00:00
Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
)
2018-07-13 12:10:39 +00:00
get_file_path = common_utils.ChunkedPath("federation_cache")
class LibraryTrack(models.Model):
url = models.URLField(unique=True, max_length=500)
audio_url = models.URLField(max_length=500)
audio_mimetype = models.CharField(max_length=200)
2018-06-09 13:36:16 +00:00
audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
2018-06-09 13:36:16 +00:00
modification_date = models.DateTimeField(auto_now=True)
fetched_date = models.DateTimeField(null=True, blank=True)
published_date = models.DateTimeField(null=True, blank=True)
library = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Library, related_name="tracks", on_delete=models.CASCADE
)
artist_name = models.CharField(max_length=500)
album_title = models.CharField(max_length=500)
title = models.CharField(max_length=500)
metadata = JSONField(
default=empty_dict, max_length=10000, encoder=DjangoJSONEncoder, blank=True
)
@property
def mbid(self):
try:
2018-06-09 13:36:16 +00:00
return self.metadata["recording"]["musicbrainz_id"]
except KeyError:
pass
def download_audio(self):
from . import actors
2018-06-09 13:36:16 +00:00
auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
remote_response = session.get_session().get(
self.audio_url,
auth=auth,
stream=True,
timeout=20,
2020-03-02 16:23:03 +00:00
headers={"Accept": "application/activity+json"},
)
with remote_response as r:
remote_response.raise_for_status()
extension = music_utils.get_ext_from_type(self.audio_mimetype)
2018-06-09 13:36:16 +00:00
title = " - ".join([self.title, self.album_title, self.artist_name])
filename = f"{title}.{extension}"
tmp_file = tempfile.TemporaryFile()
for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk)
self.audio_file.save(filename, tmp_file)
def get_metadata(self, key):
return self.metadata.get(key)
@receiver(pre_save, sender=LibraryFollow)
def set_approved_updated(sender, instance, update_fields, **kwargs):
if not instance.pk or not instance.actor.is_local:
return
if update_fields is not None and "approved" not in update_fields:
return
db_value = instance.__class__.objects.filter(pk=instance.pk).values_list(
"approved", flat=True
)[0]
if db_value != instance.approved:
# Needed to update denormalized permissions
setattr(instance, "_approved_updated", True)
@receiver(post_save, sender=LibraryFollow)
def update_denormalization_follow_approved(sender, instance, created, **kwargs):
from funkwhale_api.music import models as music_models
updated = getattr(instance, "_approved_updated", False)
if (created or updated) and instance.actor.is_local:
music_models.TrackActor.create_entries(
instance.target,
actor_ids=[instance.actor.pk],
delete_existing=not instance.approved,
)
@receiver(post_delete, sender=LibraryFollow)
def update_denormalization_follow_deleted(sender, instance, **kwargs):
from funkwhale_api.music import models as music_models
if instance.actor.is_local:
music_models.TrackActor.objects.filter(
actor=instance.actor, upload__in=instance.target.uploads.all()
).delete()
class UserFollow(AbstractFollow):
actor = models.ForeignKey(
Actor, related_name="user_follows", on_delete=models.CASCADE
)
target = models.ForeignKey(
Actor, related_name="received_user_follows", on_delete=models.CASCADE
)
class Meta:
unique_together = ["actor", "target"]