funkwhale/api/funkwhale_api/federation/models.py

357 wiersze
12 KiB
Python

import tempfile
import uuid
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils
from funkwhale_api.music import utils as music_utils
from . import utils as federation_utils
TYPE_CHOICES = [
("Person", "Person"),
("Application", "Application"),
("Group", "Group"),
("Organization", "Organization"),
("Service", "Service"),
]
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
class ActorQuerySet(models.QuerySet):
def local(self, include=True):
return self.exclude(user__isnull=include)
def with_current_usage(self):
qs = self
for s in ["pending", "skipped", "errored", "finished"]:
qs = qs.annotate(
**{
"_usage_{}".format(s): models.Sum(
"libraries__uploads__size",
filter=models.Q(libraries__uploads__import_status=s),
)
}
)
return qs
class Actor(models.Model):
ap_type = "Actor"
fid = models.URLField(unique=True, max_length=500, db_index=True)
url = models.URLField(max_length=500, null=True, blank=True)
outbox_url = models.URLField(max_length=500)
inbox_url = models.URLField(max_length=500)
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)
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
name = models.CharField(max_length=200, null=True, blank=True)
domain = models.CharField(max_length=1000)
summary = models.CharField(max_length=500, null=True, blank=True)
preferred_username = models.CharField(max_length=200, null=True, blank=True)
public_key = models.TextField(max_length=5000, null=True, blank=True)
private_key = models.TextField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None)
followers = models.ManyToManyField(
to="self",
symmetrical=False,
through="Follow",
through_fields=("target", "actor"),
related_name="following",
)
objects = ActorQuerySet.as_manager()
class Meta:
unique_together = ["domain", "preferred_username"]
@property
def webfinger_subject(self):
return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
@property
def private_key_id(self):
return "{}#main-key".format(self.fid)
@property
def full_username(self):
return "{}@{}".format(self.preferred_username, self.domain)
def __str__(self):
return "{}@{}".format(self.preferred_username, self.domain)
def save(self, **kwargs):
lowercase_fields = ["domain"]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)
@property
def is_local(self):
return self.domain == settings.FEDERATION_HOSTNAME
@property
def is_system(self):
from . import actors
return all(
[
settings.FEDERATION_HOSTNAME == self.domain,
self.preferred_username in actors.SYSTEM_ACTORS,
]
)
@property
def system_conf(self):
from . import actors
if self.is_system:
return actors.SYSTEM_ACTORS[self.preferred_username]
def get_approved_followers(self):
follows = self.received_follows.filter(approved=True)
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def should_autoapprove_follow(self, actor):
return False
def get_user(self):
try:
return self.user
except ObjectDoesNotExist:
return None
def get_current_usage(self):
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
data = {}
for s in ["pending", "skipped", "errored", "finished"]:
data[s] = getattr(actor, "_usage_{}".format(s)) or 0
data["total"] = sum(data.values())
return data
class InboxItem(models.Model):
"""
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")])
is_read = models.BooleanField(default=False)
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
)
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)
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
object_id = models.IntegerField(null=True)
object_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
related_object_id = models.IntegerField(null=True)
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
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.NullBooleanField(default=None)
class Meta:
abstract = True
def get_federation_id(self):
return federation_utils.full_url(
"{}#follows/{}".format(self.actor.fid, self.uuid)
)
class Follow(AbstractFollow):
actor = models.ForeignKey(
Actor, related_name="emitted_follows", on_delete=models.CASCADE
)
target = models.ForeignKey(
Actor, related_name="received_follows", on_delete=models.CASCADE
)
class Meta:
unique_together = ["actor", "target"]
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"]
class Library(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
fetched_date = models.DateTimeField(null=True, blank=True)
actor = models.OneToOneField(
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(
Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
)
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)
audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
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(
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
)
@property
def mbid(self):
try:
return self.metadata["recording"]["musicbrainz_id"]
except KeyError:
pass
def download_audio(self):
from . import actors
auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
remote_response = session.get_session().get(
self.audio_url,
auth=auth,
stream=True,
timeout=20,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={"Content-Type": "application/activity+json"},
)
with remote_response as r:
remote_response.raise_for_status()
extension = music_utils.get_ext_from_type(self.audio_mimetype)
title = " - ".join([self.title, self.album_title, self.artist_name])
filename = "{}.{}".format(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)