発散解像度 -divergence resolution-
Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
parent
1ff613dee6
commit
01bb65f8da
457 changed files with 929 additions and 602 deletions
0
api/funkwhale_api/federation/__init__.py
Normal file
0
api/funkwhale_api/federation/__init__.py
Normal file
565
api/funkwhale_api/federation/activity.py
Normal file
565
api/funkwhale_api/federation/activity.py
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
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 = [
|
||||
"Accept",
|
||||
"Add",
|
||||
"Announce",
|
||||
"Arrive",
|
||||
"Block",
|
||||
"Create",
|
||||
"Delete",
|
||||
"Dislike",
|
||||
"Flag",
|
||||
"Follow",
|
||||
"Ignore",
|
||||
"Invite",
|
||||
"Join",
|
||||
"Leave",
|
||||
"Like",
|
||||
"Listen",
|
||||
"Move",
|
||||
"Offer",
|
||||
"Question",
|
||||
"Reject",
|
||||
"Read",
|
||||
"Remove",
|
||||
"TentativeReject",
|
||||
"TentativeAccept",
|
||||
"Travel",
|
||||
"Undo",
|
||||
"Update",
|
||||
"View",
|
||||
]
|
||||
|
||||
FUNQUAIL_OBJECT_TYPES = [
|
||||
("Domain", "Domain"),
|
||||
("Artist", "Artist"),
|
||||
("Album", "Album"),
|
||||
("Track", "Track"),
|
||||
("Library", "Library"),
|
||||
]
|
||||
OBJECT_TYPES = (
|
||||
[
|
||||
"Application",
|
||||
"Article",
|
||||
"Audio",
|
||||
"Collection",
|
||||
"Document",
|
||||
"Event",
|
||||
"Group",
|
||||
"Image",
|
||||
"Note",
|
||||
"Object",
|
||||
"OrderedCollection",
|
||||
"Organization",
|
||||
"Page",
|
||||
"Person",
|
||||
"Place",
|
||||
"Profile",
|
||||
"Relationship",
|
||||
"Service",
|
||||
"Tombstone",
|
||||
"Video",
|
||||
]
|
||||
+ ACTIVITY_TYPES
|
||||
+ FUNQUAIL_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):
|
||||
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
|
||||
|
||||
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 schedule_key_rotation(actor_id, delay):
|
||||
from . import tasks
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
queries = {"followed": None, "actors": []}
|
||||
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))
|
||||
)
|
||||
|
||||
library_follows = models.LibraryFollow.objects.filter(
|
||||
queries["followed"], approved=True
|
||||
)
|
||||
final_query = funkwhale_utils.join_queries_or(
|
||||
final_query, Q(pk__in=library_follows.values_list("actor", flat=True))
|
||||
)
|
||||
if not final_query:
|
||||
return models.Actor.objects.none()
|
||||
return models.Actor.objects.filter(final_query)
|
||||
75
api/funkwhale_api/federation/actors.py
Normal file
75
api/funkwhale_api/federation/actors.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.common import preferences, session
|
||||
from funkwhale_api.users import models as users_models
|
||||
|
||||
from . import keys, models, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_actor_data(actor_url):
|
||||
logger.debug("Fetching actor %s", actor_url)
|
||||
response = session.get_session().get(
|
||||
actor_url,
|
||||
headers={"Accept": "application/activity+json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid actor payload: {response.text}")
|
||||
|
||||
|
||||
def get_actor(fid, skip_cache=False):
|
||||
if not skip_cache:
|
||||
try:
|
||||
actor = models.Actor.objects.select_related().get(fid=fid)
|
||||
except models.Actor.DoesNotExist:
|
||||
actor = None
|
||||
fetch_delta = datetime.timedelta(
|
||||
minutes=preferences.get("federation__actor_fetch_delay")
|
||||
)
|
||||
if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
|
||||
# cache is hot, we can return as is
|
||||
return actor
|
||||
data = get_actor_data(fid)
|
||||
serializer = serializers.ActorSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
return serializer.save(last_fetch_date=timezone.now())
|
||||
|
||||
|
||||
_CACHE = {}
|
||||
|
||||
|
||||
def get_service_actor(cache=True):
|
||||
if cache and "service_actor" in _CACHE:
|
||||
return _CACHE["service_actor"]
|
||||
|
||||
name, domain = (
|
||||
settings.FEDERATION_SERVICE_ACTOR_USERNAME,
|
||||
settings.FEDERATION_HOSTNAME,
|
||||
)
|
||||
try:
|
||||
actor = models.Actor.objects.select_related().get(
|
||||
preferred_username=name, domain__name=domain
|
||||
)
|
||||
except models.Actor.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
_CACHE["service_actor"] = actor
|
||||
return actor
|
||||
|
||||
args = users_models.get_actor_data(name)
|
||||
private, public = keys.get_key_pair()
|
||||
args["private_key"] = private.decode("utf-8")
|
||||
args["public_key"] = public.decode("utf-8")
|
||||
args["type"] = "Service"
|
||||
actor = models.Actor.objects.create(**args)
|
||||
_CACHE["service_actor"] = actor
|
||||
return actor
|
||||
100
api/funkwhale_api/federation/admin.py
Normal file
100
api/funkwhale_api/federation/admin.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from funkwhale_api.common import admin
|
||||
|
||||
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)
|
||||
class ActorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"fid",
|
||||
"domain",
|
||||
"preferred_username",
|
||||
"type",
|
||||
"creation_date",
|
||||
"last_fetch_date",
|
||||
]
|
||||
search_fields = ["fid", "domain__name", "preferred_username"]
|
||||
list_filter = ["type"]
|
||||
|
||||
|
||||
@admin.register(models.Follow)
|
||||
class FollowAdmin(admin.ModelAdmin):
|
||||
list_display = ["actor", "target", "approved", "creation_date"]
|
||||
list_filter = ["approved"]
|
||||
search_fields = ["actor__fid", "target__fid"]
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.LibraryFollow)
|
||||
class LibraryFollowAdmin(admin.ModelAdmin):
|
||||
list_display = ["actor", "target", "approved", "creation_date"]
|
||||
list_filter = ["approved"]
|
||||
search_fields = ["actor__fid", "target__fid"]
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.InboxItem)
|
||||
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 = [
|
||||
"inbox_url",
|
||||
"activity",
|
||||
"last_attempt_date",
|
||||
"attempts",
|
||||
"is_delivered",
|
||||
]
|
||||
list_filter = ["activity__type", "is_delivered"]
|
||||
search_fields = ["inbox_url"]
|
||||
list_select_related = True
|
||||
actions = [redeliver_deliveries]
|
||||
277
api/funkwhale_api/federation/api_serializers.py
Normal file
277
api/funkwhale_api/federation/api_serializers.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import datetime
|
||||
|
||||
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.common import fields as common_fields
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_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",
|
||||
"description",
|
||||
"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
|
||||
|
||||
|
||||
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":
|
||||
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)
|
||||
|
||||
|
||||
FETCH_OBJECT_CONFIG = {
|
||||
"artist": {"queryset": music_models.Artist.objects.all()},
|
||||
"album": {"queryset": music_models.Album.objects.all()},
|
||||
"track": {"queryset": music_models.Track.objects.all()},
|
||||
"library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"},
|
||||
"upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"},
|
||||
"account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"},
|
||||
"channel": {"queryset": audio_models.Channel.objects.all(), "id_attr": "uuid"},
|
||||
}
|
||||
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
|
||||
|
||||
|
||||
class FetchSerializer(serializers.ModelSerializer):
|
||||
actor = federation_serializers.APIActorSerializer(read_only=True)
|
||||
object = serializers.CharField(write_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",
|
||||
"force",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"url",
|
||||
"actor",
|
||||
"status",
|
||||
"detail",
|
||||
"creation_date",
|
||||
"fetch_date",
|
||||
]
|
||||
|
||||
def validate_object(self, value):
|
||||
# if value is a webginfer lookup, we craft a special url
|
||||
if value.startswith("@"):
|
||||
value = value.lstrip("@")
|
||||
validator = validators.EmailValidator()
|
||||
try:
|
||||
validator(value)
|
||||
except validators.ValidationError:
|
||||
return value
|
||||
|
||||
return f"webfinger://{value}"
|
||||
|
||||
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"],
|
||||
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"]
|
||||
)
|
||||
return fetch
|
||||
|
||||
def to_representation(self, obj):
|
||||
repr = super().to_representation(obj)
|
||||
object_data = None
|
||||
if obj.object:
|
||||
object_data = FETCH_OBJECT_FIELD.to_representation(obj.object)
|
||||
repr["object"] = object_data
|
||||
return repr
|
||||
|
||||
|
||||
class FullActorSerializer(serializers.Serializer):
|
||||
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
|
||||
13
api/funkwhale_api/federation/api_urls.py
Normal file
13
api/funkwhale_api/federation/api_urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from funkwhale_api.common import routers
|
||||
|
||||
from . import api_views
|
||||
|
||||
router = routers.OptionalSlashRouter()
|
||||
router.register(r"fetches", api_views.FetchViewSet, "fetches")
|
||||
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-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
|
||||
313
api/funkwhale_api/federation/api_views.py
Normal file
313
api/funkwhale_api/federation/api_views.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
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 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)
|
||||
return queryset.get(preferred_username=username, domain_id=domain)
|
||||
|
||||
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.FUNQUAIL_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)
|
||||
)
|
||||
)
|
||||
98
api/funkwhale_api/federation/authentication.py
Normal file
98
api/funkwhale_api/federation/authentication.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import datetime
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import cryptography
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
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, exceptions, keys, models, signing, tasks, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate_actor(self, request):
|
||||
headers = utils.clean_wsgi_headers(request.META)
|
||||
try:
|
||||
signature = headers["Signature"]
|
||||
key_id = keys.get_key_id_from_signature_header(signature)
|
||||
except KeyError:
|
||||
return
|
||||
except ValueError as 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:
|
||||
actor = actors.get_actor(actor_url)
|
||||
except Exception as 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:
|
||||
raise rest_exceptions.AuthenticationFailed("No public key found")
|
||||
|
||||
try:
|
||||
signing.verify_django(request, actor.public_key.encode("utf-8"))
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
# 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 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
|
||||
|
||||
def authenticate(self, request):
|
||||
setattr(request, "actor", None)
|
||||
actor = self.authenticate_actor(request)
|
||||
if not actor:
|
||||
return
|
||||
user = AnonymousUser()
|
||||
setattr(request, "actor", actor)
|
||||
return (user, None)
|
||||
381
api/funkwhale_api/federation/contexts.py
Normal file
381
api/funkwhale_api/federation/contexts.py
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
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.laidback.moe/ns",
|
||||
"document": {
|
||||
"@context": {
|
||||
"id": "@id",
|
||||
"type": "@type",
|
||||
"as": "https://www.w3.org/ns/activitystreams#",
|
||||
"fw": "https://funkwhale.laidback.moe/ns#",
|
||||
"schema": "http://schema.org#",
|
||||
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||
"Album": "fw:Album",
|
||||
"Track": "fw:Track",
|
||||
"Artist": "fw:Artist",
|
||||
"Library": "fw:Library",
|
||||
"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"},
|
||||
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
|
||||
"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",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"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"])
|
||||
50
api/funkwhale_api/federation/decorators.py
Normal file
50
api/funkwhale_api/federation/decorators.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from django.db import transaction
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import decorators, permissions, response, status
|
||||
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
|
||||
from . import api_serializers, filters, models, tasks, utils
|
||||
|
||||
|
||||
def fetches_route():
|
||||
@transaction.atomic
|
||||
def fetches(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if request.method == "GET":
|
||||
queryset = models.Fetch.objects.get_for_object(obj).select_related("actor")
|
||||
queryset = queryset.order_by("-creation_date")
|
||||
filterset = filters.FetchFilter(request.GET, queryset=queryset)
|
||||
page = self.paginate_queryset(filterset.qs)
|
||||
if page is not None:
|
||||
serializer = api_serializers.FetchSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = api_serializers.FetchSerializer(queryset, many=True)
|
||||
return response.Response(serializer.data)
|
||||
if request.method == "POST":
|
||||
if utils.is_local(obj.fid):
|
||||
return response.Response(
|
||||
{"detail": "Cannot fetch a local object"}, status=400
|
||||
)
|
||||
|
||||
fetch = models.Fetch.objects.create(
|
||||
url=obj.fid, actor=request.user.actor, object=obj
|
||||
)
|
||||
common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk)
|
||||
serializer = api_serializers.FetchSerializer(fetch)
|
||||
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return extend_schema(methods=["post"], responses=api_serializers.FetchSerializer())(
|
||||
extend_schema(
|
||||
methods=["get"],
|
||||
responses=api_serializers.FetchSerializer(many=True),
|
||||
parameters=[OpenApiParameter("id", location="query", exclude=True)],
|
||||
)(
|
||||
decorators.action(
|
||||
methods=["get", "post"],
|
||||
detail=True,
|
||||
permission_classes=[permissions.IsAuthenticated],
|
||||
)(fetches)
|
||||
)
|
||||
)
|
||||
63
api/funkwhale_api/federation/dynamic_preferences_registry.py
Normal file
63
api/funkwhale_api/federation/dynamic_preferences_registry.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
federation = types.Section("federation")
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class MusicCacheDuration(types.IntPreference):
|
||||
show_in_api = True
|
||||
section = federation
|
||||
name = "music_cache_duration"
|
||||
default = 60 * 24 * 2
|
||||
verbose_name = "Music cache duration"
|
||||
help_text = (
|
||||
"How many minutes do you want to keep a copy of federated tracks "
|
||||
"locally? Federated files that were not listened in this interval "
|
||||
"will be erased and refetched from the remote on the next listening."
|
||||
)
|
||||
field_kwargs = {"required": False}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class Enabled(types.BooleanPreference):
|
||||
section = federation
|
||||
name = "enabled"
|
||||
default = True
|
||||
verbose_name = "Federation enabled"
|
||||
help_text = (
|
||||
"Use this setting to enable or disable federation logic and API" " globally."
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class CollectionPageSize(types.IntPreference):
|
||||
section = federation
|
||||
name = "collection_page_size"
|
||||
default = 50
|
||||
verbose_name = "Federation collection page size"
|
||||
help_text = "How many items to display in ActivityPub collections."
|
||||
field_kwargs = {"required": False}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class ActorFetchDelay(types.IntPreference):
|
||||
section = federation
|
||||
name = "actor_fetch_delay"
|
||||
default = 60 * 12
|
||||
verbose_name = "Federation actor fetch delay"
|
||||
help_text = (
|
||||
"How many minutes to wait before refetching actors on "
|
||||
"request authentication."
|
||||
)
|
||||
field_kwargs = {"required": False}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class PublicIndex(types.BooleanPreference):
|
||||
show_in_api = True
|
||||
section = federation
|
||||
name = "public_index"
|
||||
default = True
|
||||
verbose_name = "Enable public index"
|
||||
help_text = "If this is enabled, public channels and libraries will be crawlable by other pods and bots"
|
||||
13
api/funkwhale_api/federation/exceptions.py
Normal file
13
api/funkwhale_api/federation/exceptions.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from rest_framework import exceptions
|
||||
|
||||
|
||||
class MalformedPayload(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingSignature(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class BlockedActorOrDomain(exceptions.AuthenticationFailed):
|
||||
pass
|
||||
329
api/funkwhale_api/federation/factories.py
Normal file
329
api/funkwhale_api/federation/factories.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import uuid
|
||||
|
||||
import factory
|
||||
import requests
|
||||
import requests_http_message_signatures
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.http import http_date
|
||||
|
||||
from funkwhale_api.factories import NoUpdateOnCreate, registry
|
||||
from funkwhale_api.users import factories as user_factories
|
||||
|
||||
from . import keys, models
|
||||
|
||||
registry.register(keys.get_key_pair, name="federation.KeyPair")
|
||||
|
||||
|
||||
@registry.register(name="federation.SignatureAuth")
|
||||
class SignatureAuthFactory(factory.Factory):
|
||||
algorithm = "rsa-sha256"
|
||||
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
|
||||
key_id = factory.Faker("url")
|
||||
headers = ["(request-target)", "user-agent", "host", "date", "accept"]
|
||||
|
||||
class Meta:
|
||||
model = requests_http_message_signatures.HTTPSignatureHeaderAuth
|
||||
|
||||
|
||||
@registry.register(name="federation.SignedRequest")
|
||||
class SignedRequestFactory(factory.Factory):
|
||||
url = factory.Faker("url")
|
||||
method = "get"
|
||||
auth = factory.SubFactory(SignatureAuthFactory)
|
||||
|
||||
class Meta:
|
||||
model = requests.Request
|
||||
|
||||
@factory.post_generation
|
||||
def headers(self, create, extracted, **kwargs):
|
||||
default_headers = {
|
||||
"User-Agent": "Test",
|
||||
"Host": "test.host",
|
||||
"Date": http_date(timezone.now().timestamp()),
|
||||
"Accept": "application/activity+json",
|
||||
}
|
||||
if extracted:
|
||||
default_headers.update(extracted)
|
||||
self.headers.update(default_headers)
|
||||
|
||||
|
||||
@registry.register(name="federation.Link")
|
||||
class LinkFactory(factory.Factory):
|
||||
type = "Link"
|
||||
href = factory.Faker("url")
|
||||
mediaType = "text/html"
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
class Params:
|
||||
audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"]))
|
||||
|
||||
|
||||
def create_user(actor):
|
||||
return user_factories.UserFactory(actor=actor)
|
||||
|
||||
|
||||
@registry.register
|
||||
class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
name = factory.Faker("domain_name")
|
||||
nodeinfo_fetch_date = factory.LazyFunction(lambda: timezone.now())
|
||||
allowed = None
|
||||
reachable = True
|
||||
last_successful_contact = None
|
||||
|
||||
class Meta:
|
||||
model = "federation.Domain"
|
||||
django_get_or_create = ("name",)
|
||||
|
||||
@factory.post_generation
|
||||
def with_service_actor(self, create, extracted, **kwargs):
|
||||
if not create or not extracted:
|
||||
return
|
||||
|
||||
self.service_actor = ActorFactory(domain=self)
|
||||
self.save(update_fields=["service_actor"])
|
||||
return self.service_actor
|
||||
|
||||
|
||||
_CACHE = {}
|
||||
|
||||
|
||||
def get_cached_key_pair():
|
||||
try:
|
||||
return _CACHE["keys"]
|
||||
except KeyError:
|
||||
_CACHE["keys"] = keys.get_key_pair()
|
||||
return _CACHE["keys"]
|
||||
|
||||
|
||||
@registry.register
|
||||
class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
public_key = None
|
||||
private_key = None
|
||||
preferred_username = factory.Faker("user_name")
|
||||
summary = factory.Faker("paragraph")
|
||||
domain = factory.SubFactory(DomainFactory)
|
||||
fid = factory.LazyAttribute(
|
||||
lambda o: f"https://{o.domain.name}/users/{o.preferred_username}"
|
||||
)
|
||||
followers_url = factory.LazyAttribute(
|
||||
lambda o: "https://{}/users/{}followers".format(
|
||||
o.domain.name, o.preferred_username
|
||||
)
|
||||
)
|
||||
inbox_url = factory.LazyAttribute(
|
||||
lambda o: "https://{}/users/{}/inbox".format(
|
||||
o.domain.name, o.preferred_username
|
||||
)
|
||||
)
|
||||
outbox_url = factory.LazyAttribute(
|
||||
lambda o: "https://{}/users/{}/outbox".format(
|
||||
o.domain.name, o.preferred_username
|
||||
)
|
||||
)
|
||||
keys = factory.LazyFunction(get_cached_key_pair)
|
||||
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
|
||||
class Params:
|
||||
with_real_keys = factory.Trait(
|
||||
keys=factory.LazyFunction(keys.get_key_pair),
|
||||
)
|
||||
|
||||
@factory.post_generation
|
||||
def local(self, create, extracted, **kwargs):
|
||||
if not extracted and not kwargs:
|
||||
return
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
self.domain = models.Domain.objects.get_or_create(
|
||||
name=settings.FEDERATION_HOSTNAME
|
||||
)[0]
|
||||
self.fid = f"https://{self.domain}/actors/{self.preferred_username}"
|
||||
self.save(update_fields=["domain", "fid"])
|
||||
if not create:
|
||||
if extracted and hasattr(extracted, "pk"):
|
||||
extracted.actor = self
|
||||
else:
|
||||
UserFactory.build(actor=self, **kwargs)
|
||||
if extracted and hasattr(extracted, "pk"):
|
||||
extracted.actor = self
|
||||
extracted.save(update_fields=["user"])
|
||||
else:
|
||||
self.user = UserFactory(actor=self, **kwargs)
|
||||
|
||||
|
||||
@registry.register
|
||||
class FollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
target = factory.SubFactory(ActorFactory)
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = models.Follow
|
||||
|
||||
class Params:
|
||||
local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
|
||||
|
||||
|
||||
@registry.register
|
||||
class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
privacy_level = "me"
|
||||
name = factory.Faker("sentence")
|
||||
description = factory.Faker("sentence")
|
||||
uploads_count = 0
|
||||
fid = factory.Faker("federation_url")
|
||||
followers_url = factory.LazyAttribute(
|
||||
lambda o: o.fid + "/followers" if o.fid else None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = "music.Library"
|
||||
|
||||
class Params:
|
||||
local = factory.Trait(
|
||||
fid=None, actor=factory.SubFactory(ActorFactory, local=True)
|
||||
)
|
||||
|
||||
|
||||
@registry.register
|
||||
class LibraryScanFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
library = factory.SubFactory(MusicLibraryFactory)
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
total_files = factory.LazyAttribute(lambda o: o.library.uploads_count)
|
||||
|
||||
class Meta:
|
||||
model = "music.LibraryScan"
|
||||
|
||||
|
||||
@registry.register
|
||||
class FetchFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = "federation.Fetch"
|
||||
|
||||
|
||||
@registry.register
|
||||
class ActivityFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
url = factory.Faker("federation_url")
|
||||
payload = factory.LazyFunction(lambda: {"type": "Create"})
|
||||
|
||||
class Meta:
|
||||
model = "federation.Activity"
|
||||
|
||||
|
||||
@registry.register
|
||||
class InboxItemFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
actor = factory.SubFactory(ActorFactory, local=True)
|
||||
activity = factory.SubFactory(ActivityFactory)
|
||||
type = "to"
|
||||
|
||||
class Meta:
|
||||
model = "federation.InboxItem"
|
||||
|
||||
|
||||
@registry.register
|
||||
class DeliveryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
activity = factory.SubFactory(ActivityFactory)
|
||||
inbox_url = factory.Faker("url")
|
||||
|
||||
class Meta:
|
||||
model = "federation.Delivery"
|
||||
|
||||
|
||||
@registry.register
|
||||
class LibraryFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
target = factory.SubFactory(MusicLibraryFactory)
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = "federation.LibraryFollow"
|
||||
|
||||
|
||||
class ArtistMetadataFactory(factory.Factory):
|
||||
name = factory.Faker("name")
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
class Params:
|
||||
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||
|
||||
|
||||
class ReleaseMetadataFactory(factory.Factory):
|
||||
title = factory.Faker("sentence")
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
class Params:
|
||||
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||
|
||||
|
||||
class RecordingMetadataFactory(factory.Factory):
|
||||
title = factory.Faker("sentence")
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
class Params:
|
||||
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||
|
||||
|
||||
@registry.register(name="federation.LibraryTrackMetadata")
|
||||
class LibraryTrackMetadataFactory(factory.Factory):
|
||||
artist = factory.SubFactory(ArtistMetadataFactory)
|
||||
recording = factory.SubFactory(RecordingMetadataFactory)
|
||||
release = factory.SubFactory(ReleaseMetadataFactory)
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
|
||||
@registry.register(name="federation.Note")
|
||||
class NoteFactory(factory.Factory):
|
||||
type = "Note"
|
||||
id = factory.Faker("url")
|
||||
published = factory.LazyFunction(lambda: timezone.now().isoformat())
|
||||
inReplyTo = None
|
||||
content = factory.Faker("sentence")
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
|
||||
@registry.register(name="federation.AudioMetadata")
|
||||
class AudioMetadataFactory(factory.Factory):
|
||||
recording = factory.LazyAttribute(
|
||||
lambda o: f"https://musicbrainz.org/recording/{uuid.uuid4()}"
|
||||
)
|
||||
artist = factory.LazyAttribute(
|
||||
lambda o: f"https://musicbrainz.org/artist/{uuid.uuid4()}"
|
||||
)
|
||||
release = factory.LazyAttribute(
|
||||
lambda o: f"https://musicbrainz.org/release/{uuid.uuid4()}"
|
||||
)
|
||||
bitrate = 42
|
||||
length = 43
|
||||
size = 44
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
||||
|
||||
@registry.register(name="federation.Audio")
|
||||
class AudioFactory(factory.Factory):
|
||||
type = "Audio"
|
||||
id = factory.Faker("federation_url")
|
||||
published = factory.LazyFunction(lambda: timezone.now().isoformat())
|
||||
actor = factory.Faker("federation_url")
|
||||
url = factory.SubFactory(LinkFactory, audio=True)
|
||||
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
31
api/funkwhale_api/federation/fields.py
Normal file
31
api/funkwhale_api/federation/fields.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import django_filters
|
||||
from rest_framework import serializers
|
||||
|
||||
from . import models, utils
|
||||
|
||||
|
||||
class ActorRelatedField(serializers.EmailField):
|
||||
def to_representation(self, value):
|
||||
return value.full_username
|
||||
|
||||
def to_internal_value(self, value):
|
||||
value = super().to_internal_value(value)
|
||||
username, domain = value.split("@")
|
||||
try:
|
||||
return models.Actor.objects.get(
|
||||
preferred_username=username, domain_id=domain
|
||||
)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise serializers.ValidationError("Invalid actor name")
|
||||
|
||||
|
||||
class DomainFromURLFilter(django_filters.CharFilter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.url_field = kwargs.pop("url_field", "fid")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, qs, value):
|
||||
if not value:
|
||||
return qs
|
||||
query = utils.get_domain_query_from_url(value, self.url_field)
|
||||
return qs.filter(query)
|
||||
59
api/funkwhale_api/federation/filters.py
Normal file
59
api/funkwhale_api/federation/filters.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import django_filters.widgets
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class FollowFilter(django_filters.FilterSet):
|
||||
pending = django_filters.CharFilter(method="filter_pending")
|
||||
ordering = django_filters.OrderingFilter(
|
||||
# tuple-mapping retains order
|
||||
fields=(
|
||||
("creation_date", "creation_date"),
|
||||
("modification_date", "modification_date"),
|
||||
)
|
||||
)
|
||||
q = fields.SearchFilter(
|
||||
search_fields=["actor__domain", "actor__preferred_username"]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Follow
|
||||
fields = ["approved"]
|
||||
|
||||
def filter_pending(self, queryset, field_name, value):
|
||||
if value.lower() in ["true", "1", "yes"]:
|
||||
queryset = queryset.filter(approved__isnull=True)
|
||||
return queryset
|
||||
|
||||
|
||||
class LibraryFollowFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = models.LibraryFollow
|
||||
fields = ["approved"]
|
||||
|
||||
|
||||
class InboxItemFilter(django_filters.FilterSet):
|
||||
is_read = django_filters.BooleanFilter(
|
||||
"is_read", widget=django_filters.widgets.BooleanWidget()
|
||||
)
|
||||
before = django_filters.NumberFilter(method="filter_before")
|
||||
|
||||
class Meta:
|
||||
model = models.InboxItem
|
||||
fields = ["is_read", "activity__type", "activity__actor"]
|
||||
|
||||
def filter_before(self, queryset, field_name, value):
|
||||
return queryset.filter(pk__lte=value)
|
||||
|
||||
|
||||
class FetchFilter(django_filters.FilterSet):
|
||||
ordering = django_filters.OrderingFilter(
|
||||
# tuple-mapping retains order
|
||||
fields=(("creation_date", "creation_date"), ("fetch_date", "fetch_date"))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Fetch
|
||||
fields = ["status", "object_id", "url"]
|
||||
326
api/funkwhale_api/federation/jsonld.py
Normal file
326
api/funkwhale_api/federation/jsonld.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
import pyld.documentloader.requests
|
||||
import pyld.jsonld
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import empty
|
||||
|
||||
from . import contexts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cached_contexts(loader):
|
||||
functools.wraps(loader)
|
||||
|
||||
def load(url, *args, **kwargs):
|
||||
for cached in contexts.CONTEXTS:
|
||||
if url == cached["documentUrl"]:
|
||||
return cached
|
||||
if cached["shortId"] == "LITEPUB" and "/schemas/litepub-" in url:
|
||||
# XXX UGLY fix for pleroma because they host their schema
|
||||
# under each instance domain, which makes caching harder
|
||||
return cached
|
||||
return loader(url, *args, **kwargs)
|
||||
|
||||
return load
|
||||
|
||||
|
||||
def get_document_loader():
|
||||
loader = pyld.documentloader.requests.requests_document_loader(
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
|
||||
)
|
||||
return cached_contexts(loader)
|
||||
|
||||
|
||||
def expand(doc, options=None, default_contexts=["AS", "FW", "SEC"]):
|
||||
options = options or {}
|
||||
options.setdefault("documentLoader", get_document_loader())
|
||||
if isinstance(doc, str):
|
||||
doc = options["documentLoader"](doc)["document"]
|
||||
for context_name in default_contexts:
|
||||
ctx = contexts.CONTEXTS_BY_ID[context_name]["documentUrl"]
|
||||
try:
|
||||
insert_context(ctx, doc)
|
||||
except KeyError:
|
||||
# probably an already expanded document
|
||||
pass
|
||||
|
||||
# XXX This is a hotfix for a bug in pyld. The JSON-LD allows empty dicts or lists as part of the
|
||||
# context, but this makes pyld failing to parse the context the right way. So we remove all
|
||||
# empty items from the contexts
|
||||
try:
|
||||
for active_ctx in doc["@context"]:
|
||||
if len(active_ctx) == 0:
|
||||
doc["@context"].remove(active_ctx)
|
||||
except KeyError:
|
||||
# Nothing to do here if no context is available at all
|
||||
pass
|
||||
|
||||
result = pyld.jsonld.expand(doc, options=options)
|
||||
try:
|
||||
# jsonld.expand returns a list, which is useless for us
|
||||
return result[0]
|
||||
except IndexError:
|
||||
raise ValueError("Impossible to expand this jsonld document")
|
||||
|
||||
|
||||
def insert_context(ctx, doc):
|
||||
"""
|
||||
In some situations, we may want to add a default context to an existing document.
|
||||
This function enable that (this will mutate the original document)
|
||||
"""
|
||||
existing = doc["@context"]
|
||||
if isinstance(existing, list):
|
||||
if ctx not in existing:
|
||||
existing = existing[:]
|
||||
existing.append(ctx)
|
||||
doc["@context"] = existing
|
||||
else:
|
||||
doc["@context"] = [existing, ctx]
|
||||
return doc
|
||||
|
||||
|
||||
def get_session():
|
||||
return aiohttp.ClientSession(raise_for_status=True)
|
||||
|
||||
|
||||
async def fetch_json(url, session, cache=None, lock=None):
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return url, await response.json()
|
||||
|
||||
|
||||
async def fetch_many(*ids, references=None):
|
||||
"""
|
||||
Given a list of object ids, will fetch the remote
|
||||
representations for those objects, expand them
|
||||
and return a dictionary with id as the key and expanded document as the values
|
||||
"""
|
||||
ids = set(ids)
|
||||
results = references if references is not None else {}
|
||||
|
||||
if not ids:
|
||||
return results
|
||||
|
||||
async with get_session() as session:
|
||||
tasks = [fetch_json(url, session) for url in ids if url not in results]
|
||||
tasks_results = await asyncio.gather(*tasks)
|
||||
|
||||
for url, payload in tasks_results:
|
||||
results[url] = payload
|
||||
|
||||
return results
|
||||
|
||||
|
||||
DEFAULT_PREPARE_CONFIG = {
|
||||
"type": {"property": "@type", "keep": "first"},
|
||||
"id": {"property": "@id"},
|
||||
}
|
||||
|
||||
|
||||
def dereference(value, references):
|
||||
"""
|
||||
Given a payload and a dictionary containing ids and objects, will replace
|
||||
all the matching objects in the payload by the one in the references dictionary.
|
||||
"""
|
||||
|
||||
def replace(obj, id):
|
||||
try:
|
||||
matching = references[id]
|
||||
except KeyError:
|
||||
return
|
||||
# we clear the current dict, and replace its content by the matching obj
|
||||
obj.clear()
|
||||
obj.update(matching)
|
||||
|
||||
if isinstance(value, dict):
|
||||
if "@id" in value:
|
||||
replace(value, value["@id"])
|
||||
else:
|
||||
for attr in value.values():
|
||||
dereference(attr, references)
|
||||
|
||||
elif isinstance(value, list):
|
||||
# we loop on nested objects and trigger dereferencing
|
||||
for obj in value:
|
||||
dereference(obj, references)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def get_value(value, keep=None, attr=None):
|
||||
if keep == "first":
|
||||
value = value[0]
|
||||
if attr:
|
||||
value = value[attr]
|
||||
|
||||
elif attr:
|
||||
value = [obj[attr] for obj in value if attr in obj]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def prepare_for_serializer(payload, config, fallbacks={}):
|
||||
"""
|
||||
Json-ld payloads, as returned by expand are quite complex to handle, because
|
||||
every attr is basically a list of dictionaries. To make code simpler,
|
||||
we use this function to clean the payload a little bit, base on the config object.
|
||||
|
||||
Config is a dictionary, with keys being serializer field names, and values
|
||||
being dictionaries describing how to handle this field.
|
||||
"""
|
||||
final_payload = {}
|
||||
final_config = {}
|
||||
final_config.update(DEFAULT_PREPARE_CONFIG)
|
||||
final_config.update(config)
|
||||
for field, field_config in final_config.items():
|
||||
try:
|
||||
value = get_value(
|
||||
payload[field_config["property"]],
|
||||
keep=field_config.get("keep"),
|
||||
attr=field_config.get("attr"),
|
||||
)
|
||||
except (IndexError, KeyError):
|
||||
aliases = field_config.get("aliases", {})
|
||||
noop = object()
|
||||
value = noop
|
||||
if not aliases:
|
||||
continue
|
||||
|
||||
for a in aliases:
|
||||
try:
|
||||
value = get_value(
|
||||
payload[a["property"]],
|
||||
keep=a.get("keep"),
|
||||
attr=a.get("attr"),
|
||||
)
|
||||
except (IndexError, KeyError):
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if value is noop:
|
||||
continue
|
||||
|
||||
final_payload[field] = value
|
||||
|
||||
for key, choices in fallbacks.items():
|
||||
if key in final_payload:
|
||||
# initial attr was found, no need to rely on fallbacks
|
||||
continue
|
||||
|
||||
for choice in choices:
|
||||
if choice not in final_payload:
|
||||
continue
|
||||
|
||||
final_payload[key] = final_payload[choice]
|
||||
|
||||
return final_payload
|
||||
|
||||
|
||||
def get_ids(v):
|
||||
if isinstance(v, dict) and "@id" in v:
|
||||
yield v["@id"]
|
||||
|
||||
if isinstance(v, list):
|
||||
for obj in v:
|
||||
yield from get_ids(obj)
|
||||
|
||||
|
||||
def get_default_context():
|
||||
return [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
"https://funkwhale.laidback.moe/ns",
|
||||
{
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"Hashtag": "as:Hashtag",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class JsonLdSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.jsonld_expand = kwargs.pop("jsonld_expand", True)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.jsonld_context = []
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
if data and data is not empty:
|
||||
self.jsonld_context = data.get("@context", [])
|
||||
if self.context.get("expand", self.jsonld_expand):
|
||||
try:
|
||||
data = expand(data)
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(
|
||||
f"{data} is not a valid jsonld document: {e}"
|
||||
)
|
||||
try:
|
||||
config = self.Meta.jsonld_mapping
|
||||
except AttributeError:
|
||||
config = {}
|
||||
try:
|
||||
fallbacks = self.Meta.jsonld_fallbacks
|
||||
except AttributeError:
|
||||
fallbacks = {}
|
||||
|
||||
data = prepare_for_serializer(data, config, fallbacks=fallbacks)
|
||||
dereferenced_fields = [
|
||||
k
|
||||
for k, c in config.items()
|
||||
if k in data and c.get("dereference", False)
|
||||
]
|
||||
dereferenced_ids = set()
|
||||
for field in dereferenced_fields:
|
||||
for i in get_ids(data[field]):
|
||||
dereferenced_ids.add(i)
|
||||
|
||||
if dereferenced_ids:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError as exception:
|
||||
logger.debug(exception)
|
||||
loop = asyncio.new_event_loop()
|
||||
references = self.context.setdefault("references", {})
|
||||
loop.run_until_complete(
|
||||
fetch_many(*dereferenced_ids, references=references)
|
||||
)
|
||||
data = dereference(data, references)
|
||||
return super().run_validation(data)
|
||||
|
||||
|
||||
def first_attr(property, attr, aliases=[]):
|
||||
return {"property": property, "keep": "first", "attr": attr, "aliases": aliases}
|
||||
|
||||
|
||||
def first_val(property, aliases=[]):
|
||||
return first_attr(property, "@value", aliases=aliases)
|
||||
|
||||
|
||||
def first_id(property, aliases=[]):
|
||||
return first_attr(property, "@id", aliases=aliases)
|
||||
|
||||
|
||||
def first_obj(property, aliases=[]):
|
||||
return {"property": property, "keep": "first", "aliases": aliases}
|
||||
|
||||
|
||||
def raw(property, aliases=[]):
|
||||
return {"property": property, "aliases": aliases}
|
||||
|
||||
|
||||
def is_present_recursive(data, key):
|
||||
if isinstance(data, (dict, list)):
|
||||
for v in data:
|
||||
if is_present_recursive(v, key):
|
||||
return True
|
||||
else:
|
||||
if data == key:
|
||||
return True
|
||||
|
||||
return False
|
||||
47
api/funkwhale_api/federation/keys.py
Normal file
47
api/funkwhale_api/federation/keys.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import re
|
||||
import urllib.parse
|
||||
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from django.conf import settings
|
||||
|
||||
KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"")
|
||||
|
||||
|
||||
def get_key_pair(size=None):
|
||||
size = size or settings.RSA_KEY_SIZE
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(), public_exponent=65537, key_size=size
|
||||
)
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption(),
|
||||
)
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
def get_key_id_from_signature_header(header_string):
|
||||
parts = header_string.split(",")
|
||||
try:
|
||||
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
|
||||
except IndexError:
|
||||
raise ValueError("Missing key id")
|
||||
|
||||
match = KEY_ID_REGEX.match(raw_key_id)
|
||||
if not match:
|
||||
raise ValueError("Invalid key id")
|
||||
|
||||
key_id = match.groups()[0]
|
||||
url = urllib.parse.urlparse(key_id)
|
||||
if not url.scheme or not url.netloc:
|
||||
raise ValueError("Invalid url")
|
||||
if url.scheme not in ["http", "https"]:
|
||||
raise ValueError("Invalid shceme")
|
||||
return key_id
|
||||
44
api/funkwhale_api/federation/library.py
Normal file
44
api/funkwhale_api/federation/library.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import requests
|
||||
|
||||
from funkwhale_api.common import session
|
||||
|
||||
from . import serializers, signing
|
||||
|
||||
|
||||
def get_library_data(library_url, actor):
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
try:
|
||||
response = session.get_session().get(
|
||||
library_url,
|
||||
auth=auth,
|
||||
headers={"Accept": "application/activity+json"},
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
return {"errors": ["This library is not reachable"]}
|
||||
scode = response.status_code
|
||||
if scode == 401:
|
||||
return {"errors": ["This library requires authentication"]}
|
||||
elif scode == 403:
|
||||
return {"errors": ["Permission denied while scanning library"]}
|
||||
elif scode >= 400:
|
||||
return {"errors": [f"Error {scode} while fetching the library"]}
|
||||
serializer = serializers.LibrarySerializer(data=response.json())
|
||||
if not serializer.is_valid():
|
||||
return {"errors": ["Invalid ActivityPub response from remote library"]}
|
||||
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def get_library_page(library, page_url, actor):
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
response = session.get_session().get(
|
||||
page_url,
|
||||
auth=auth,
|
||||
headers={"Accept": "application/activity+json"},
|
||||
)
|
||||
serializer = serializers.CollectionPageSerializer(
|
||||
data=response.json(),
|
||||
context={"library": library, "item_serializer": serializers.UploadSerializer},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.validated_data
|
||||
0
api/funkwhale_api/federation/management/__init__.py
Normal file
0
api/funkwhale_api/federation/management/__init__.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from funkwhale_api.common import utils
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
MODELS = [
|
||||
(music_models.Artist, ["fid"]),
|
||||
(music_models.Album, ["fid"]),
|
||||
(music_models.Track, ["fid"]),
|
||||
(music_models.Upload, ["fid"]),
|
||||
(music_models.Library, ["fid", "followers_url"]),
|
||||
(
|
||||
federation_models.Actor,
|
||||
[
|
||||
"fid",
|
||||
"url",
|
||||
"outbox_url",
|
||||
"inbox_url",
|
||||
"following_url",
|
||||
"followers_url",
|
||||
"shared_inbox_url",
|
||||
],
|
||||
),
|
||||
(federation_models.Activity, ["fid"]),
|
||||
(federation_models.Follow, ["fid"]),
|
||||
(federation_models.LibraryFollow, ["fid"]),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """
|
||||
Find and replace wrong protocol/domain in local federation ids.
|
||||
|
||||
Use with caution and only if you know what you are doing.
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"old_base_url",
|
||||
type=str,
|
||||
help="The invalid url prefix you want to find and replace, e.g 'http://baddomain'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"new_base_url",
|
||||
type=str,
|
||||
help="The url prefix you want to use in place of the bad one, e.g 'https://gooddomain'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--noinput",
|
||||
"--no-input",
|
||||
action="store_false",
|
||||
dest="interactive",
|
||||
help="Do NOT prompt the user for input of any kind.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-dry-run",
|
||||
action="store_false",
|
||||
dest="dry_run",
|
||||
help="Commit the changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
results = {}
|
||||
old_prefix, new_prefix = options["old_base_url"], options["new_base_url"]
|
||||
for kls, fields in MODELS:
|
||||
results[kls] = {}
|
||||
for field in fields:
|
||||
candidates = kls.objects.filter(**{f"{field}__startswith": old_prefix})
|
||||
results[kls][field] = candidates.count()
|
||||
|
||||
total = sum([t for k in results.values() for t in k.values()])
|
||||
self.stdout.write("")
|
||||
if total:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Will replace {} found occurrences of '{}' by '{}':".format(
|
||||
total, old_prefix, new_prefix
|
||||
)
|
||||
)
|
||||
)
|
||||
self.stdout.write("")
|
||||
for kls, fields in results.items():
|
||||
for field, count in fields.items():
|
||||
self.stdout.write(
|
||||
"- {}/{} {}.{}".format(
|
||||
count, kls.objects.count(), kls._meta.label, field
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
self.stdout.write(f"No objects found with prefix {old_prefix}, exiting.")
|
||||
return
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(
|
||||
"Run this command with --no-dry-run to perform the replacement."
|
||||
)
|
||||
return
|
||||
self.stdout.write("")
|
||||
if options["interactive"]:
|
||||
message = (
|
||||
"Are you sure you want to perform the replacement on {} objects?\n\n"
|
||||
"Type 'yes' to continue, or 'no' to cancel: "
|
||||
).format(total)
|
||||
if input("".join(message)) != "yes":
|
||||
raise CommandError("Command canceled.")
|
||||
|
||||
for kls, fields in results.items():
|
||||
for field, count in fields.items():
|
||||
self.stdout.write(f"Replacing {field} on {count} {kls._meta.label}…")
|
||||
candidates = kls.objects.all()
|
||||
utils.replace_prefix(candidates, field, old=old_prefix, new=new_prefix)
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.SUCCESS("Done!"))
|
||||
81
api/funkwhale_api/federation/migrations/0001_initial.py
Normal file
81
api/funkwhale_api/federation/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-31 13:43
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Actor",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField(db_index=True, max_length=500, unique=True)),
|
||||
("outbox_url", models.URLField(max_length=500)),
|
||||
("inbox_url", models.URLField(max_length=500)),
|
||||
(
|
||||
"following_url",
|
||||
models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
(
|
||||
"followers_url",
|
||||
models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
(
|
||||
"shared_inbox_url",
|
||||
models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Person", "Person"),
|
||||
("Application", "Application"),
|
||||
("Group", "Group"),
|
||||
("Organization", "Organization"),
|
||||
("Service", "Service"),
|
||||
],
|
||||
default="Person",
|
||||
max_length=25,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, max_length=200, null=True)),
|
||||
("domain", models.CharField(max_length=1000)),
|
||||
("summary", models.CharField(blank=True, max_length=500, null=True)),
|
||||
(
|
||||
"preferred_username",
|
||||
models.CharField(blank=True, max_length=200, null=True),
|
||||
),
|
||||
(
|
||||
"public_key",
|
||||
models.CharField(blank=True, max_length=5000, null=True),
|
||||
),
|
||||
(
|
||||
"private_key",
|
||||
models.CharField(blank=True, max_length=5000, null=True),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
(
|
||||
"last_fetch_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("manually_approves_followers", models.BooleanField(default=None, null=True)),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-03 16:20
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="actor", unique_together={("domain", "preferred_username")}
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-07 10:10
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
def delete_system_actors(apps, schema_editor):
|
||||
"""Revert site domain and name to default."""
|
||||
Actor = apps.get_model("federation", "Actor")
|
||||
Actor.objects.filter(preferred_username__in=["test", "library"]).delete()
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0002_auto_20180403_1620")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_system_actors, backward),
|
||||
migrations.CreateModel(
|
||||
name="Follow",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="emitted_follows",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_follows",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FollowRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
("approved", models.BooleanField(default=None, null=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="emmited_follow_requests",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_follow_requests",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Library",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
("fetched_date", models.DateTimeField(blank=True, null=True)),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4)),
|
||||
("url", models.URLField()),
|
||||
("federation_enabled", models.BooleanField()),
|
||||
("download_files", models.BooleanField()),
|
||||
("autoimport", models.BooleanField()),
|
||||
("tracks_count", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"actor",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="library",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="LibraryTrack",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField(unique=True)),
|
||||
("audio_url", models.URLField()),
|
||||
("audio_mimetype", models.CharField(max_length=200)),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
("fetched_date", models.DateTimeField(blank=True, null=True)),
|
||||
("published_date", models.DateTimeField(blank=True, null=True)),
|
||||
("artist_name", models.CharField(max_length=500)),
|
||||
("album_title", models.CharField(max_length=500)),
|
||||
("title", models.CharField(max_length=500)),
|
||||
(
|
||||
"metadata",
|
||||
django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default={}, max_length=10000
|
||||
),
|
||||
),
|
||||
(
|
||||
"library",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tracks",
|
||||
to="federation.Library",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="actor",
|
||||
name="followers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="following",
|
||||
through="federation.Follow",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="follow", unique_together={("actor", "target")}
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-10 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0003_auto_20180407_1010")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="followrequest", name="actor"),
|
||||
migrations.RemoveField(model_name="followrequest", name="target"),
|
||||
migrations.AddField(
|
||||
model_name="follow",
|
||||
name="approved",
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="library",
|
||||
name="follow",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="library",
|
||||
to="federation.Follow",
|
||||
),
|
||||
),
|
||||
migrations.DeleteModel(name="FollowRequest"),
|
||||
]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-13 17:23
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0004_auto_20180410_2025")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="librarytrack",
|
||||
name="audio_file",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=funkwhale_api.federation.models.get_file_path,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="librarytrack",
|
||||
name="metadata",
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default={},
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||
max_length=10000,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-21 17:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0005_auto_20180413_1723")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="library", name="url", field=models.URLField(max_length=500)
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="librarytrack",
|
||||
name="audio_url",
|
||||
field=models.URLField(max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="librarytrack",
|
||||
name="url",
|
||||
field=models.URLField(max_length=500, unique=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Generated by Django 2.0.7 on 2018-08-07 17:48
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0006_auto_20180521_1702")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Activity",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
(
|
||||
"fid",
|
||||
models.URLField(blank=True, max_length=500, null=True, unique=True),
|
||||
),
|
||||
("url", models.URLField(blank=True, max_length=500, null=True)),
|
||||
(
|
||||
"payload",
|
||||
django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default={},
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||
max_length=50000,
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("delivered", models.BooleanField(default=None, null=True)),
|
||||
("delivered_date", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="LibraryFollow",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fid",
|
||||
models.URLField(blank=True, max_length=500, null=True, unique=True),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
("approved", models.BooleanField(default=None, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.RenameField("actor", "url", "fid"),
|
||||
migrations.AddField(
|
||||
model_name="actor",
|
||||
name="url",
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="follow",
|
||||
name="fid",
|
||||
field=models.URLField(blank=True, max_length=500, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="libraryfollow",
|
||||
name="actor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="library_follows",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 2.0.7 on 2018-08-07 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0029_auto_20180807_1748"),
|
||||
("federation", "0007_auto_20180807_1748"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="libraryfollow",
|
||||
name="target",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_follows",
|
||||
to="music.Library",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="activity",
|
||||
name="actor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="activities",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="libraryfollow", unique_together={("actor", "target")}
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Generated by Django 2.0.8 on 2018-08-22 19:56
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0008_auto_20180807_1748")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="activity",
|
||||
name="recipient",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="inbox_activities",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="activity",
|
||||
name="payload",
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default=funkwhale_api.federation.models.empty_dict,
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||
max_length=50000,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="librarytrack",
|
||||
name="metadata",
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default=funkwhale_api.federation.models.empty_dict,
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||
max_length=10000,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
# Generated by Django 2.0.8 on 2018-09-04 20:11
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0009_auto_20180822_1956")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InboxItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("is_delivered", models.BooleanField(default=False)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("to", "to"), ("cc", "cc")], max_length=10
|
||||
),
|
||||
),
|
||||
("last_delivery_date", models.DateTimeField(blank=True, null=True)),
|
||||
("delivery_attempts", models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(model_name="activity", name="delivered"),
|
||||
migrations.RemoveField(model_name="activity", name="delivered_date"),
|
||||
migrations.RemoveField(model_name="activity", name="recipient"),
|
||||
migrations.AlterField(
|
||||
model_name="activity",
|
||||
name="actor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="outbox_activities",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="inboxitem",
|
||||
name="activity",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="inbox_items",
|
||||
to="federation.Activity",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="inboxitem",
|
||||
name="actor",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="inbox_items",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="activity",
|
||||
name="recipients",
|
||||
field=models.ManyToManyField(
|
||||
related_name="inbox_activities",
|
||||
through="federation.InboxItem",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Generated by Django 2.0.8 on 2018-09-10 19:02
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('federation', '0010_auto_20180904_2011'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='object_content_type',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='object_id',
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='related_object_content_type',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='related_object_id',
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='target_content_type',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='target_id',
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='type',
|
||||
field=models.CharField(db_index=True, max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inboxitem',
|
||||
name='is_read',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='creation_date',
|
||||
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 2.0.8 on 2018-09-20 18:03
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0011_auto_20180910_1902'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Delivery',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_delivered', models.BooleanField(default=False)),
|
||||
('last_attempt_date', models.DateTimeField(blank=True, null=True)),
|
||||
('attempts', models.PositiveIntegerField(default=0)),
|
||||
('inbox_url', models.URLField(max_length=500)),
|
||||
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='federation.Activity')),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inboxitem',
|
||||
name='delivery_attempts',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inboxitem',
|
||||
name='is_delivered',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inboxitem',
|
||||
name='last_delivery_date',
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 2.0.9 on 2018-12-26 19:35
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0012_auto_20180920_1803")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="actor",
|
||||
name="private_key",
|
||||
field=models.TextField(blank=True, max_length=5000, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="actor",
|
||||
name="public_key",
|
||||
field=models.TextField(blank=True, max_length=5000, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Generated by Django 2.0.9 on 2018-12-05 09:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0013_auto_20181226_1935")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Domain",
|
||||
fields=[
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=255, primary_key=True, serialize=False),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="actor",
|
||||
name="domain",
|
||||
field=models.CharField(max_length=1000, null=True),
|
||||
),
|
||||
migrations.RenameField("actor", "domain", "old_domain"),
|
||||
migrations.AddField(
|
||||
model_name="actor",
|
||||
name="domain",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="actors",
|
||||
to="federation.Domain",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(name="actor", unique_together=set()),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="actor", unique_together={("domain", "preferred_username")}
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 2.0.9 on 2018-11-14 08:55
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
def populate_domains(apps, schema_editor):
|
||||
Domain = apps.get_model("federation", "Domain")
|
||||
Actor = apps.get_model("federation", "Actor")
|
||||
|
||||
domains = set(
|
||||
[v.lower() for v in Actor.objects.values_list("old_domain", flat=True) if v]
|
||||
)
|
||||
for domain in sorted(domains):
|
||||
print("Populating domain {}...".format(domain))
|
||||
first_actor = (
|
||||
Actor.objects.order_by("creation_date")
|
||||
.exclude(creation_date=None)
|
||||
.filter(old_domain__iexact=domain)
|
||||
.first()
|
||||
)
|
||||
|
||||
if first_actor:
|
||||
first_seen = first_actor.creation_date
|
||||
else:
|
||||
first_seen = django.utils.timezone.now()
|
||||
|
||||
Domain.objects.update_or_create(
|
||||
name=domain, defaults={"creation_date": first_seen}
|
||||
)
|
||||
|
||||
for domain in Domain.objects.all():
|
||||
Actor.objects.filter(old_domain__iexact=domain.name).update(domain=domain)
|
||||
|
||||
|
||||
def skip(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0014_auto_20181205_0958")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_domains, skip),
|
||||
migrations.AlterField(
|
||||
model_name="actor",
|
||||
name="domain",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="actors",
|
||||
to="federation.Domain",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 2.0.9 on 2018-12-27 16:05
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0015_populate_domains")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="domain",
|
||||
name="nodeinfo",
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(
|
||||
default=funkwhale_api.federation.models.empty_dict, max_length=50000
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domain",
|
||||
name="nodeinfo_fetch_date",
|
||||
field=models.DateTimeField(blank=True, default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 2.1.5 on 2019-01-30 09:26
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import funkwhale_api.common.validators
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0016_auto_20181227_1605'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='actor',
|
||||
name='old_domain',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='service_actor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_domains', to='federation.Actor'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='domain',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, primary_key=True, serialize=False, validators=[funkwhale_api.common.validators.DomainValidator()]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='domain',
|
||||
name='nodeinfo',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, max_length=50000),
|
||||
),
|
||||
]
|
||||
33
api/funkwhale_api/federation/migrations/0018_fetch.py
Normal file
33
api/funkwhale_api/federation/migrations/0018_fetch.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 2.1.7 on 2019-04-17 14:57
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('federation', '0017_auto_20190130_0926'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Fetch',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(db_index=True, max_length=500)),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('fetch_date', models.DateTimeField(blank=True, null=True)),
|
||||
('object_id', models.IntegerField(null=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('errored', 'Errored'), ('finished', 'Finished'), ('skipped', 'Skipped')], default='pending', max_length=20)),
|
||||
('detail', django.contrib.postgres.fields.jsonb.JSONField(default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000)),
|
||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fetches', to='federation.Actor')),
|
||||
('object_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 2.2.2 on 2019-06-11 08:51
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0018_fetch")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="domain",
|
||||
name="allowed",
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-30 08:46
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0019_auto_20190611_0851'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='payload',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='fetch',
|
||||
name='detail',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='librarytrack',
|
||||
name='metadata',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-29 12:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0020_auto_20190730_0846'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='actor',
|
||||
options={'verbose_name': 'Account'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='actor',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('Person', 'Person'), ('Tombstone', 'Tombstone'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 2.2.7 on 2019-12-04 15:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0021_auto_20191029_1257'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='actor',
|
||||
name='inbox_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='actor',
|
||||
name='outbox_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 2.2.9 on 2020-01-22 11:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0007_auto_20200116_1610'),
|
||||
('federation', '0022_auto_20191204_1539'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='actor',
|
||||
name='summary_obj',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 2.2.9 on 2020-01-23 13:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0007_auto_20200116_1610'),
|
||||
('federation', '0023_actor_summary_obj'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='actor',
|
||||
name='attachment_icon',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='iconed_actor', to='common.Attachment'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.0.4 on 2020-03-17 08:20
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('federation', '0024_actor_attachment_icon'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='object_content_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='object_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='related_object_content_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='related_object_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='target_content_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='target_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 2.0.9 on 2018-11-14 08:55
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
def update_public_key_format(apps, schema_editor):
|
||||
"""
|
||||
Reserialize keys in proper format (PKCS#8 instead of #1)
|
||||
https://github.com/friendica/friendica/issues/7771#issuecomment-603019826
|
||||
"""
|
||||
Actor = apps.get_model("federation", "Actor")
|
||||
|
||||
local_actors = list(
|
||||
Actor.objects.exclude(private_key="")
|
||||
.exclude(private_key=None)
|
||||
.only("pk", "private_key", "public_key")
|
||||
.order_by("id")
|
||||
)
|
||||
|
||||
total = len(local_actors)
|
||||
if total:
|
||||
print("{} keys to update...".format(total))
|
||||
else:
|
||||
print("Skipping")
|
||||
return
|
||||
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
for actor in local_actors:
|
||||
private_key = crypto_serialization.load_pem_private_key(
|
||||
actor.private_key.encode(), password=None, backend=default_backend()
|
||||
)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
actor.public_key = public_key.decode()
|
||||
|
||||
Actor.objects.bulk_update(local_actors, ["public_key"])
|
||||
print("Done!")
|
||||
|
||||
|
||||
def skip(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("federation", "0025_auto_20200317_0820")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_public_key_format, skip),
|
||||
]
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 3.2.13 on 2022-06-27 19:15
|
||||
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0026_public_key_format'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='payload',
|
||||
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='actor',
|
||||
name='manually_approves_followers',
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='domain',
|
||||
name='nodeinfo',
|
||||
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, max_length=50000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='fetch',
|
||||
name='detail',
|
||||
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='follow',
|
||||
name='approved',
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='libraryfollow',
|
||||
name='approved',
|
||||
field=models.BooleanField(default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='librarytrack',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.2.16 on 2022-10-27 11:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0027_auto_20220627_1915'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='last_successful_contact',
|
||||
field=models.DateTimeField(default=None, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='reachable',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
0
api/funkwhale_api/federation/migrations/__init__.py
Normal file
0
api/funkwhale_api/federation/migrations/__init__.py
Normal file
640
api/funkwhale_api/federation/models.py
Normal file
640
api/funkwhale_api/federation/models.py
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
import tempfile
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
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.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
|
||||
|
||||
from funkwhale_api.common import session
|
||||
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
|
||||
|
||||
TYPE_CHOICES = [
|
||||
("Person", "Person"),
|
||||
("Tombstone", "Tombstone"),
|
||||
("Application", "Application"),
|
||||
("Group", "Group"),
|
||||
("Organization", "Organization"),
|
||||
("Service", "Service"),
|
||||
]
|
||||
|
||||
MAX_LENGTHS = {
|
||||
"ACTOR_NAME": 200,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
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
|
||||
|
||||
|
||||
class ActorQuerySet(models.QuerySet):
|
||||
def local(self, include=True):
|
||||
if include:
|
||||
return self.filter(domain__name=settings.FEDERATION_HOSTNAME)
|
||||
return self.exclude(domain__name=settings.FEDERATION_HOSTNAME)
|
||||
|
||||
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
|
||||
|
||||
def with_uploads_count(self):
|
||||
return self.annotate(
|
||||
uploads_count=models.Count("libraries__uploads", distinct=True)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
|
||||
nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
|
||||
service_actor = models.ForeignKey(
|
||||
"Actor",
|
||||
related_name="managed_domains",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
# 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)
|
||||
|
||||
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),
|
||||
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),
|
||||
)
|
||||
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()
|
||||
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
|
||||
def is_local(self) -> bool:
|
||||
return self.name == settings.FEDERATION_HOSTNAME
|
||||
|
||||
|
||||
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, null=True, blank=True)
|
||||
inbox_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
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=MAX_LENGTHS["ACTOR_NAME"], null=True, blank=True)
|
||||
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
|
||||
summary = models.CharField(max_length=500, null=True, blank=True)
|
||||
summary_obj = models.ForeignKey(
|
||||
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
|
||||
)
|
||||
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.BooleanField(default=None, null=True)
|
||||
followers = models.ManyToManyField(
|
||||
to="self",
|
||||
symmetrical=False,
|
||||
through="Follow",
|
||||
through_fields=("target", "actor"),
|
||||
related_name="following",
|
||||
)
|
||||
attachment_icon = models.ForeignKey(
|
||||
"common.Attachment",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="iconed_actor",
|
||||
)
|
||||
|
||||
objects = ActorQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
unique_together = ["domain", "preferred_username"]
|
||||
verbose_name = "Account"
|
||||
|
||||
def get_moderation_url(self):
|
||||
return f"/manage/moderation/accounts/{self.full_username}"
|
||||
|
||||
@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"
|
||||
|
||||
@property
|
||||
def full_username(self) -> str:
|
||||
return f"{self.preferred_username}@{self.domain_id}"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.preferred_username}@{self.domain_id}"
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
return self.domain_id == settings.FEDERATION_HOSTNAME
|
||||
|
||||
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):
|
||||
if self.get_channel():
|
||||
return True
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
@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")
|
||||
|
||||
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}/"
|
||||
)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.name or self.preferred_username
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
actor = models.ForeignKey(Actor, related_name="fetches", on_delete=models.CASCADE)
|
||||
|
||||
objects = FetchQuerySet.as_manager()
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.url and self.object and hasattr(self.object, "fid"):
|
||||
self.url = self.object.fid
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def serializers(self):
|
||||
from . import contexts, serializers
|
||||
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
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, blank=True
|
||||
)
|
||||
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, blank=True)
|
||||
object_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="objecting_activities",
|
||||
)
|
||||
object = GenericForeignKey("object_content_type", "object_id")
|
||||
target_id = models.IntegerField(null=True, blank=True)
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="targeting_activities",
|
||||
)
|
||||
target = GenericForeignKey("target_content_type", "target_id")
|
||||
related_object_id = models.IntegerField(null=True, blank=True)
|
||||
related_object_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=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.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):
|
||||
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, blank=True
|
||||
)
|
||||
|
||||
@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,
|
||||
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)
|
||||
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()
|
||||
14
api/funkwhale_api/federation/mrf_policies.py
Normal file
14
api/funkwhale_api/federation/mrf_policies.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from funkwhale_api.moderation import mrf
|
||||
|
||||
from . import activity
|
||||
|
||||
|
||||
@mrf.inbox.register(name="instance_policies")
|
||||
def instance_policies(payload, **kwargs):
|
||||
reject = activity.should_reject(
|
||||
fid=payload.get("id"),
|
||||
actor_id=kwargs.get("sender_id", payload.get("id")),
|
||||
payload=payload,
|
||||
)
|
||||
if reject:
|
||||
raise mrf.Discard()
|
||||
5
api/funkwhale_api/federation/parsers.py
Normal file
5
api/funkwhale_api/federation/parsers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import parsers
|
||||
|
||||
|
||||
class ActivityParser(parsers.JSONParser):
|
||||
media_type = "application/activity+json"
|
||||
34
api/funkwhale_api/federation/renderers.py
Normal file
34
api/funkwhale_api/federation/renderers.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from rest_framework.negotiation import BaseContentNegotiation
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
|
||||
def get_ap_renderers():
|
||||
MEDIA_TYPES = [
|
||||
("APActivity", "application/activity+json"),
|
||||
("APLD", "application/ld+json"),
|
||||
("APJSON", "application/json"),
|
||||
("HTML", "text/html"),
|
||||
]
|
||||
|
||||
return [
|
||||
type(name, (JSONRenderer,), {"media_type": media_type})
|
||||
for name, media_type in MEDIA_TYPES
|
||||
]
|
||||
|
||||
|
||||
class IgnoreClientContentNegotiation(BaseContentNegotiation):
|
||||
def select_parser(self, request, parsers):
|
||||
"""
|
||||
Select the first parser in the `.parser_classes` list.
|
||||
"""
|
||||
return parsers[0]
|
||||
|
||||
def select_renderer(self, request, renderers, format_suffix):
|
||||
"""
|
||||
Select the first renderer in the `.renderer_classes` list.
|
||||
"""
|
||||
return (renderers[0], renderers[0].media_type)
|
||||
|
||||
|
||||
class WebfingerRenderer(JSONRenderer):
|
||||
media_type = "application/jrd+json"
|
||||
610
api/funkwhale_api/federation/routes.py
Normal file
610
api/funkwhale_api/federation/routes.py
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from . import activity, actors, models, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
inbox = activity.InboxRouter()
|
||||
outbox = activity.OutboxRouter()
|
||||
|
||||
|
||||
def with_recipients(payload, to=[], cc=[]):
|
||||
if to:
|
||||
payload["to"] = to
|
||||
if cc:
|
||||
payload["cc"] = cc
|
||||
return payload
|
||||
|
||||
|
||||
@inbox.register({"type": "Follow"})
|
||||
def inbox_follow(payload, context):
|
||||
context["recipient"] = [
|
||||
ii.actor for ii in context["inbox_items"] if ii.type == "to"
|
||||
][0]
|
||||
serializer = serializers.FollowSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.debug(
|
||||
"Discarding invalid follow from {}: %s",
|
||||
context["actor"].fid,
|
||||
serializer.errors,
|
||||
)
|
||||
return
|
||||
|
||||
autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
|
||||
context["actor"]
|
||||
)
|
||||
follow = serializer.save(approved=True if autoapprove else None)
|
||||
if follow.approved:
|
||||
outbox.dispatch({"type": "Accept"}, context={"follow": follow})
|
||||
return {"object": follow.target, "related_object": follow}
|
||||
|
||||
|
||||
@inbox.register({"type": "Accept"})
|
||||
def inbox_accept(payload, context):
|
||||
context["recipient"] = [
|
||||
ii.actor for ii in context["inbox_items"] if ii.type == "to"
|
||||
][0]
|
||||
serializer = serializers.AcceptFollowSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.debug(
|
||||
"Discarding invalid accept from {}: %s",
|
||||
context["actor"].fid,
|
||||
serializer.errors,
|
||||
)
|
||||
return
|
||||
|
||||
serializer.save()
|
||||
obj = serializer.validated_data["follow"]
|
||||
return {"object": obj, "related_object": obj.target}
|
||||
|
||||
|
||||
@outbox.register({"type": "Accept"})
|
||||
def outbox_accept(context):
|
||||
follow = context["follow"]
|
||||
if follow._meta.label == "federation.LibraryFollow":
|
||||
actor = follow.target.actor
|
||||
else:
|
||||
actor = follow.target
|
||||
payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data
|
||||
yield {
|
||||
"actor": actor,
|
||||
"type": "Accept",
|
||||
"payload": with_recipients(payload, to=[follow.actor]),
|
||||
"object": follow,
|
||||
"related_object": follow.target,
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Reject"})
|
||||
def outbox_reject_follow(context):
|
||||
follow = context["follow"]
|
||||
if follow._meta.label == "federation.LibraryFollow":
|
||||
actor = follow.target.actor
|
||||
else:
|
||||
actor = follow.target
|
||||
payload = serializers.RejectFollowSerializer(follow, context={"actor": actor}).data
|
||||
yield {
|
||||
"actor": actor,
|
||||
"type": "Reject",
|
||||
"payload": with_recipients(payload, to=[follow.actor]),
|
||||
"object": follow,
|
||||
"related_object": follow.target,
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Reject"})
|
||||
def inbox_reject_follow(payload, context):
|
||||
serializer = serializers.RejectFollowSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.debug(
|
||||
"Discarding invalid follow reject from %s: %s",
|
||||
context["actor"].fid,
|
||||
serializer.errors,
|
||||
)
|
||||
return
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
@inbox.register({"type": "Undo", "object.type": "Follow"})
|
||||
def inbox_undo_follow(payload, context):
|
||||
serializer = serializers.UndoFollowSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.debug(
|
||||
"Discarding invalid follow undo from %s: %s",
|
||||
context["actor"].fid,
|
||||
serializer.errors,
|
||||
)
|
||||
return
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
@outbox.register({"type": "Undo", "object.type": "Follow"})
|
||||
def outbox_undo_follow(context):
|
||||
follow = context["follow"]
|
||||
actor = follow.actor
|
||||
if follow._meta.label == "federation.LibraryFollow":
|
||||
recipient = follow.target.actor
|
||||
else:
|
||||
recipient = follow.target
|
||||
payload = serializers.UndoFollowSerializer(follow, context={"actor": actor}).data
|
||||
yield {
|
||||
"actor": actor,
|
||||
"type": "Undo",
|
||||
"payload": with_recipients(payload, to=[recipient]),
|
||||
"object": follow,
|
||||
"related_object": follow.target,
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Follow"})
|
||||
def outbox_follow(context):
|
||||
follow = context["follow"]
|
||||
if follow._meta.label == "federation.LibraryFollow":
|
||||
target = follow.target.actor
|
||||
else:
|
||||
target = follow.target
|
||||
payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data
|
||||
yield {
|
||||
"type": "Follow",
|
||||
"actor": follow.actor,
|
||||
"payload": with_recipients(payload, to=[target]),
|
||||
"object": follow.target,
|
||||
"related_object": follow,
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Create", "object.type": "Audio"})
|
||||
def outbox_create_audio(context):
|
||||
upload = context["upload"]
|
||||
channel = upload.library.get_channel()
|
||||
followers_target = channel.actor if channel else upload.library
|
||||
actor = channel.actor if channel else upload.library.actor
|
||||
if channel:
|
||||
serializer = serializers.ChannelCreateUploadSerializer(upload)
|
||||
else:
|
||||
upload_serializer = serializers.UploadSerializer
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{
|
||||
"type": "Create",
|
||||
"actor": actor.fid,
|
||||
"object": upload_serializer(upload).data,
|
||||
}
|
||||
)
|
||||
yield {
|
||||
"type": "Create",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data, to=[{"type": "followers", "target": followers_target}]
|
||||
),
|
||||
"object": upload,
|
||||
"target": None if channel else upload.library,
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Create", "object.type": "Audio"})
|
||||
def inbox_create_audio(payload, context):
|
||||
is_channel = "library" not in payload["object"]
|
||||
if is_channel:
|
||||
channel = context["actor"].get_channel()
|
||||
serializer = serializers.ChannelCreateUploadSerializer(
|
||||
data=payload,
|
||||
context={"channel": channel},
|
||||
)
|
||||
else:
|
||||
serializer = serializers.UploadSerializer(
|
||||
data=payload["object"],
|
||||
context={"activity": context.get("activity"), "actor": context["actor"]},
|
||||
)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.warn("Discarding invalid audio create: %s", serializer.errors)
|
||||
return
|
||||
|
||||
upload = serializer.save()
|
||||
if is_channel:
|
||||
return {"object": upload, "target": channel}
|
||||
else:
|
||||
return {"object": upload, "target": upload.library}
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Library"})
|
||||
def inbox_delete_library(payload, context):
|
||||
actor = context["actor"]
|
||||
library_id = payload["object"].get("id")
|
||||
if not library_id:
|
||||
logger.debug("Discarding deletion of empty library")
|
||||
return
|
||||
|
||||
try:
|
||||
library = actor.libraries.get(fid=library_id)
|
||||
except music_models.Library.DoesNotExist:
|
||||
logger.debug("Discarding deletion of unkwnown library %s", library_id)
|
||||
return
|
||||
|
||||
library.delete()
|
||||
|
||||
|
||||
@outbox.register({"type": "Delete", "object.type": "Library"})
|
||||
def outbox_delete_library(context):
|
||||
library = context["library"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Delete", "object": {"type": "Library", "id": library.fid}}
|
||||
)
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"actor": library.actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data, to=[{"type": "followers", "target": library}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Library"})
|
||||
def outbox_update_library(context):
|
||||
library = context["library"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.LibrarySerializer(library).data}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": library.actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data, to=[{"type": "followers", "target": library}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Library"})
|
||||
def inbox_update_library(payload, context):
|
||||
actor = context["actor"]
|
||||
library_id = payload["object"].get("id")
|
||||
if not library_id:
|
||||
logger.debug("Discarding deletion of empty library")
|
||||
return
|
||||
|
||||
if not actor.libraries.filter(fid=library_id).exists():
|
||||
logger.debug("Discarding deletion of unkwnown library %s", library_id)
|
||||
return
|
||||
|
||||
serializer = serializers.LibrarySerializer(data=payload["object"])
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
else:
|
||||
logger.debug(
|
||||
"Discarding update of library %s because of payload errors: %s",
|
||||
library_id,
|
||||
serializer.errors,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Audio"})
|
||||
def inbox_delete_audio(payload, context):
|
||||
actor = context["actor"]
|
||||
try:
|
||||
upload_fids = [i for i in payload["object"]["id"]]
|
||||
except TypeError:
|
||||
# we did not receive a list of Ids, so we can probably use the value directly
|
||||
upload_fids = [payload["object"]["id"]]
|
||||
|
||||
query = Q(fid__in=upload_fids) & (
|
||||
Q(library__actor=actor) | Q(track__artist__channel__actor=actor)
|
||||
)
|
||||
candidates = music_models.Upload.objects.filter(query)
|
||||
|
||||
total = candidates.count()
|
||||
logger.info("Deleting %s uploads with ids %s", total, upload_fids)
|
||||
candidates.delete()
|
||||
|
||||
|
||||
@outbox.register({"type": "Delete", "object.type": "Audio"})
|
||||
def outbox_delete_audio(context):
|
||||
uploads = context["uploads"]
|
||||
library = uploads[0].library
|
||||
channel = library.get_channel()
|
||||
followers_target = channel.actor if channel else library
|
||||
actor = channel.actor if channel else library.actor
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{
|
||||
"type": "Delete",
|
||||
"object": {"type": "Audio", "id": [u.get_federation_id() for u in uploads]},
|
||||
}
|
||||
)
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data, to=[{"type": "followers", "target": followers_target}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def handle_library_entry_update(payload, context, queryset, serializer_class):
|
||||
actor = context["actor"]
|
||||
obj_id = payload["object"].get("id")
|
||||
if not obj_id:
|
||||
logger.debug("Discarding update of empty obj")
|
||||
return
|
||||
|
||||
try:
|
||||
obj = queryset.select_related("attributed_to").get(fid=obj_id)
|
||||
except queryset.model.DoesNotExist:
|
||||
logger.debug("Discarding update of unkwnown obj %s", obj_id)
|
||||
return
|
||||
if not actor.can_manage(obj):
|
||||
logger.debug(
|
||||
"Discarding unauthorize update of obj %s from %s", obj_id, actor.fid
|
||||
)
|
||||
return
|
||||
|
||||
serializer = serializer_class(obj, data=payload["object"])
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
else:
|
||||
logger.debug(
|
||||
"Discarding update of obj %s because of payload errors: %s",
|
||||
obj_id,
|
||||
serializer.errors,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Track"})
|
||||
def inbox_update_track(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Track.objects.all(),
|
||||
serializer_class=serializers.TrackSerializer,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Audio"})
|
||||
def inbox_update_audio(payload, context):
|
||||
serializer = serializers.ChannelCreateUploadSerializer(
|
||||
data=payload, context=context
|
||||
)
|
||||
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.info("Skipped update, invalid payload")
|
||||
return
|
||||
serializer.save()
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Audio"})
|
||||
def outbox_update_audio(context):
|
||||
upload = context["upload"]
|
||||
channel = upload.library.get_channel()
|
||||
actor = channel.actor
|
||||
serializer = serializers.ChannelCreateUploadSerializer(
|
||||
upload, context={"type": "Update", "activity_id_suffix": str(uuid.uuid4())[:8]}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Artist"})
|
||||
def inbox_update_artist(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Artist.objects.all(),
|
||||
serializer_class=serializers.ArtistSerializer,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Album"})
|
||||
def inbox_update_album(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Album.objects.all(),
|
||||
serializer_class=serializers.AlbumSerializer,
|
||||
)
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Track"})
|
||||
def outbox_update_track(context):
|
||||
track = context["track"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.TrackSerializer(track).data}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": actors.get_service_actor(),
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Album"})
|
||||
def outbox_update_album(context):
|
||||
album = context["album"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.AlbumSerializer(album).data}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": actors.get_service_actor(),
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Artist"})
|
||||
def outbox_update_artist(context):
|
||||
artist = context["artist"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.ArtistSerializer(artist).data}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": actors.get_service_actor(),
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register(
|
||||
{
|
||||
"type": "Delete",
|
||||
"object.type": [
|
||||
"Tombstone",
|
||||
"Actor",
|
||||
"Person",
|
||||
"Application",
|
||||
"Organization",
|
||||
"Service",
|
||||
"Group",
|
||||
],
|
||||
}
|
||||
)
|
||||
def outbox_delete_actor(context):
|
||||
actor = context["actor"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Delete", "object": {"type": actor.type, "id": actor.fid}}
|
||||
)
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@inbox.register(
|
||||
{
|
||||
"type": "Delete",
|
||||
"object.type": [
|
||||
"Actor",
|
||||
"Person",
|
||||
"Application",
|
||||
"Organization",
|
||||
"Service",
|
||||
"Group",
|
||||
],
|
||||
}
|
||||
)
|
||||
def inbox_delete_actor(payload, context):
|
||||
actor = context["actor"]
|
||||
serializer = serializers.ActorDeleteSerializer(data=payload)
|
||||
if not serializer.is_valid():
|
||||
logger.info("Skipped actor %s deletion, invalid payload", actor.fid)
|
||||
return
|
||||
|
||||
deleted_fid = serializer.validated_data["fid"]
|
||||
try:
|
||||
# ensure the actor only can delete itself, and is a remote one
|
||||
actor = models.Actor.objects.local(False).get(fid=deleted_fid, pk=actor.pk)
|
||||
except models.Actor.DoesNotExist:
|
||||
logger.warn("Cannot delete actor %s, no matching object found", actor.fid)
|
||||
return
|
||||
actor.delete()
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Tombstone"})
|
||||
def inbox_delete(payload, context):
|
||||
serializer = serializers.DeleteSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.info("Skipped deletion, invalid payload")
|
||||
return
|
||||
|
||||
to_delete = serializer.validated_data["object"]
|
||||
to_delete.delete()
|
||||
|
||||
|
||||
@inbox.register({"type": "Flag"})
|
||||
def inbox_flag(payload, context):
|
||||
serializer = serializers.FlagSerializer(data=payload, context=context)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.debug(
|
||||
"Discarding invalid report from {}: %s",
|
||||
context["actor"].fid,
|
||||
serializer.errors,
|
||||
)
|
||||
return
|
||||
|
||||
report = serializer.save()
|
||||
return {"object": report.target, "related_object": report}
|
||||
|
||||
|
||||
@outbox.register({"type": "Flag"})
|
||||
def outbox_flag(context):
|
||||
report = context["report"]
|
||||
if not report.target or not report.target.fid:
|
||||
return
|
||||
actor = actors.get_service_actor()
|
||||
serializer = serializers.FlagSerializer(report)
|
||||
yield {
|
||||
"type": "Flag",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
# Mastodon requires the report to be sent to the reported actor inbox
|
||||
# (and not the shared inbox)
|
||||
to=[{"type": "actor_inbox", "actor": report.target_owner}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Album"})
|
||||
def inbox_delete_album(payload, context):
|
||||
actor = context["actor"]
|
||||
album_id = payload["object"].get("id")
|
||||
if not album_id:
|
||||
logger.debug("Discarding deletion of empty library")
|
||||
return
|
||||
|
||||
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor))
|
||||
try:
|
||||
album = music_models.Album.objects.get(query)
|
||||
except music_models.Album.DoesNotExist:
|
||||
logger.debug("Discarding deletion of unkwnown album %s", album_id)
|
||||
return
|
||||
|
||||
album.delete()
|
||||
|
||||
|
||||
@outbox.register({"type": "Delete", "object.type": "Album"})
|
||||
def outbox_delete_album(context):
|
||||
album = context["album"]
|
||||
actor = (
|
||||
album.artist.channel.actor
|
||||
if album.artist.get_channel()
|
||||
else album.attributed_to
|
||||
)
|
||||
actor = actor or actors.get_service_actor()
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
2579
api/funkwhale_api/federation/schema_org.py
Normal file
2579
api/funkwhale_api/federation/schema_org.py
Normal file
File diff suppressed because it is too large
Load diff
2078
api/funkwhale_api/federation/serializers.py
Normal file
2078
api/funkwhale_api/federation/serializers.py
Normal file
File diff suppressed because it is too large
Load diff
113
api/funkwhale_api/federation/signing.py
Normal file
113
api/funkwhale_api/federation/signing.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import cryptography.exceptions
|
||||
import requests
|
||||
import requests_http_message_signatures
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.http import parse_http_date
|
||||
|
||||
from . import exceptions, utils
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
else:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# the request Date should be between now - 30s and now + 30s
|
||||
DATE_HEADER_VALID_FOR = 30
|
||||
|
||||
|
||||
def verify_date(raw_date):
|
||||
if not raw_date:
|
||||
raise forms.ValidationError("Missing date header")
|
||||
|
||||
try:
|
||||
ts = parse_http_date(raw_date)
|
||||
except ValueError as e:
|
||||
raise forms.ValidationError(str(e))
|
||||
dt = datetime.datetime.utcfromtimestamp(ts)
|
||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
|
||||
now = timezone.now()
|
||||
if dt < now - delta or dt > now + delta:
|
||||
logger.debug(
|
||||
f"Request Date {raw_date} is too too far in the future or in the past"
|
||||
)
|
||||
raise forms.ValidationError(
|
||||
"Request Date is too far in the future or in the past"
|
||||
)
|
||||
|
||||
return dt
|
||||
|
||||
|
||||
def verify(request, public_key):
|
||||
date = request.headers.get("Date")
|
||||
logger.debug(
|
||||
"Verifying request with date %s and headers %s", date, str(request.headers)
|
||||
)
|
||||
verify_date(date)
|
||||
try:
|
||||
return requests_http_message_signatures.HTTPSignatureHeaderAuth.verify(
|
||||
request, key_resolver=lambda **kwargs: public_key
|
||||
)
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
logger.warning(
|
||||
"Could not verify request with date %s and headers %s",
|
||||
date,
|
||||
str(request.headers),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def verify_django(django_request, public_key):
|
||||
"""
|
||||
Given a django WSGI request, create an underlying requests.PreparedRequest
|
||||
instance we can verify
|
||||
"""
|
||||
headers = utils.clean_wsgi_headers(django_request.META)
|
||||
for h, v in list(headers.items()):
|
||||
# we include lower-cased version of the headers for compatibility
|
||||
# with requests_http_message_signatures
|
||||
headers[h.lower()] = v
|
||||
try:
|
||||
signature = headers["Signature"]
|
||||
except KeyError:
|
||||
raise exceptions.MissingSignature
|
||||
url = f"http://noop{django_request.path}"
|
||||
query = django_request.META["QUERY_STRING"]
|
||||
if query:
|
||||
url += f"?{query}"
|
||||
signature_headers = signature.split('headers="')[1].split('",')[0]
|
||||
expected = signature_headers.split(" ")
|
||||
logger.debug("Signature expected headers: %s", expected)
|
||||
for header in expected:
|
||||
if header == "(request-target)":
|
||||
# this one represent the request body, so not an actual HTTP header
|
||||
continue
|
||||
try:
|
||||
headers[header]
|
||||
except KeyError:
|
||||
logger.debug("Missing header: %s", header)
|
||||
request = requests.Request(
|
||||
method=django_request.method, url=url, data=django_request.body, headers=headers
|
||||
)
|
||||
for h in request.headers.keys():
|
||||
v = request.headers[h]
|
||||
if v:
|
||||
request.headers[h] = str(v)
|
||||
request.prepare()
|
||||
return verify(request, public_key)
|
||||
|
||||
|
||||
def get_auth(private_key, private_key_id):
|
||||
return requests_http_message_signatures.HTTPSignatureHeaderAuth(
|
||||
headers=["(request-target)", "user-agent", "host", "date"],
|
||||
algorithm="rsa-sha256",
|
||||
key=private_key.encode("utf-8"),
|
||||
key_id=private_key_id,
|
||||
)
|
||||
60
api/funkwhale_api/federation/spa_views.py
Normal file
60
api/funkwhale_api/federation/spa_views.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import middleware, preferences, utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def actor_detail_username(request, username, redirect_to_ap):
|
||||
validator = federation_utils.get_actor_data_from_username
|
||||
try:
|
||||
username_data = validator(username)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
|
||||
queryset = (
|
||||
models.Actor.objects.filter(
|
||||
preferred_username__iexact=username_data["username"]
|
||||
)
|
||||
.local()
|
||||
.select_related("attachment_icon")
|
||||
)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Actor.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.fid)
|
||||
obj_url = utils.join_url(
|
||||
settings.FUNQUAIL_URL,
|
||||
utils.spa_reverse("actor_detail", kwargs={"username": obj.preferred_username}),
|
||||
)
|
||||
metas = [
|
||||
{"tag": "meta", "property": "og:url", "content": obj_url},
|
||||
{"tag": "meta", "property": "og:title", "content": obj.display_name},
|
||||
{"tag": "meta", "property": "og:type", "content": "profile"},
|
||||
]
|
||||
|
||||
if obj.attachment_icon:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "og:image",
|
||||
"content": obj.attachment_icon.download_url_medium_square_crop,
|
||||
}
|
||||
)
|
||||
|
||||
if preferences.get("federation__enabled"):
|
||||
metas.append(
|
||||
{
|
||||
"tag": "link",
|
||||
"rel": "alternate",
|
||||
"type": "application/activity+json",
|
||||
"href": obj.fid,
|
||||
}
|
||||
)
|
||||
|
||||
return metas
|
||||
667
api/funkwhale_api/federation/tasks.py
Normal file
667
api/funkwhale_api/federation/tasks.py
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.db.models.deletion import Collector
|
||||
from django.utils import timezone
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common import models as common_models
|
||||
from funkwhale_api.common import preferences, session
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.moderation import mrf
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import (
|
||||
activity,
|
||||
actors,
|
||||
exceptions,
|
||||
jsonld,
|
||||
keys,
|
||||
models,
|
||||
routes,
|
||||
serializers,
|
||||
signing,
|
||||
utils,
|
||||
webfinger,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.app.task(name="federation.clean_music_cache")
|
||||
def clean_music_cache():
|
||||
preferences = global_preferences_registry.manager()
|
||||
delay = preferences["federation__music_cache_duration"]
|
||||
if delay < 1:
|
||||
return # cache clearing disabled
|
||||
limit = timezone.now() - datetime.timedelta(minutes=delay)
|
||||
|
||||
candidates = (
|
||||
music_models.Upload.objects.filter(
|
||||
Q(audio_file__isnull=False)
|
||||
& (Q(accessed_date__lt=limit) | Q(accessed_date=None)),
|
||||
# library__actor__user=None,
|
||||
)
|
||||
.local(False)
|
||||
.exclude(audio_file="")
|
||||
.filter(Q(source__startswith="http://") | Q(source__startswith="https://"))
|
||||
.only("audio_file", "id")
|
||||
.order_by("id")
|
||||
)
|
||||
for upload in candidates:
|
||||
upload.audio_file.delete()
|
||||
|
||||
# we also delete orphaned files, if any
|
||||
storage = models.LibraryTrack._meta.get_field("audio_file").storage
|
||||
files = get_files(storage, "federation_cache/tracks")
|
||||
existing = music_models.Upload.objects.filter(audio_file__in=files)
|
||||
missing = set(files) - set(existing.values_list("audio_file", flat=True))
|
||||
for m in missing:
|
||||
storage.delete(m)
|
||||
|
||||
|
||||
def get_files(storage, *parts):
|
||||
"""
|
||||
This is a recursive function that return all files available
|
||||
in a given directory using django's storage.
|
||||
"""
|
||||
if not parts:
|
||||
raise ValueError("Missing path")
|
||||
try:
|
||||
dirs, files = storage.listdir(os.path.join(*parts))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
for dir in dirs:
|
||||
files += get_files(storage, *(list(parts) + [dir]))
|
||||
return [os.path.join(parts[-1], path) for path in files]
|
||||
|
||||
|
||||
@celery.app.task(name="federation.dispatch_inbox")
|
||||
@celery.require_instance(models.Activity.objects.select_related(), "activity")
|
||||
def dispatch_inbox(activity, call_handlers=True):
|
||||
"""
|
||||
Given an activity instance, triggers our internal delivery logic (follow
|
||||
creation, etc.)
|
||||
"""
|
||||
|
||||
routes.inbox.dispatch(
|
||||
activity.payload,
|
||||
context={
|
||||
"activity": activity,
|
||||
"actor": activity.actor,
|
||||
"inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
|
||||
},
|
||||
call_handlers=call_handlers,
|
||||
)
|
||||
|
||||
|
||||
@celery.app.task(name="federation.dispatch_outbox")
|
||||
@celery.require_instance(models.Activity.objects.select_related(), "activity")
|
||||
def dispatch_outbox(activity):
|
||||
"""
|
||||
Deliver a local activity to its recipients, both locally and remotely
|
||||
"""
|
||||
inbox_items = activity.inbox_items.filter(is_read=False).select_related()
|
||||
|
||||
if inbox_items.exists():
|
||||
call_handlers = activity.type in ["Follow"]
|
||||
dispatch_inbox.delay(activity_id=activity.pk, call_handlers=call_handlers)
|
||||
|
||||
if not preferences.get("federation__enabled"):
|
||||
# federation is disabled, we only deliver to local recipients
|
||||
return
|
||||
|
||||
deliveries = activity.deliveries.filter(is_delivered=False)
|
||||
|
||||
for id in deliveries.values_list("pk", flat=True):
|
||||
deliver_to_remote.delay(delivery_id=id)
|
||||
|
||||
|
||||
@celery.app.task(
|
||||
name="federation.deliver_to_remote_inbox",
|
||||
autoretry_for=[RequestException],
|
||||
retry_backoff=30,
|
||||
max_retries=5,
|
||||
)
|
||||
@celery.require_instance(
|
||||
models.Delivery.objects.filter(is_delivered=False).select_related(
|
||||
"activity__actor"
|
||||
),
|
||||
"delivery",
|
||||
)
|
||||
def deliver_to_remote(delivery):
|
||||
if not preferences.get("federation__enabled"):
|
||||
# federation is disabled, we only deliver to local recipients
|
||||
return
|
||||
|
||||
actor = delivery.activity.actor
|
||||
logger.info("Preparing activity delivery to %s", delivery.inbox_url)
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
try:
|
||||
response = session.get_session().post(
|
||||
auth=auth,
|
||||
json=delivery.activity.payload,
|
||||
url=delivery.inbox_url,
|
||||
headers={"Content-Type": "application/activity+json"},
|
||||
)
|
||||
logger.debug("Remote answered with %s", response.status_code)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
delivery.last_attempt_date = timezone.now()
|
||||
delivery.attempts = F("attempts") + 1
|
||||
delivery.save(update_fields=["last_attempt_date", "attempts"])
|
||||
raise
|
||||
else:
|
||||
delivery.last_attempt_date = timezone.now()
|
||||
delivery.attempts = F("attempts") + 1
|
||||
delivery.is_delivered = True
|
||||
delivery.save(update_fields=["last_attempt_date", "attempts", "is_delivered"])
|
||||
|
||||
|
||||
def fetch_nodeinfo(domain_name):
|
||||
s = session.get_session()
|
||||
wellknown_url = f"https://{domain_name}/.well-known/nodeinfo"
|
||||
response = s.get(url=wellknown_url)
|
||||
response.raise_for_status()
|
||||
serializer = serializers.NodeInfoSerializer(data=response.json())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
nodeinfo_url = None
|
||||
for link in serializer.validated_data["links"]:
|
||||
if link["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0":
|
||||
nodeinfo_url = link["href"]
|
||||
break
|
||||
|
||||
response = s.get(url=nodeinfo_url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@celery.app.task(name="federation.update_domain_nodeinfo")
|
||||
@celery.require_instance(
|
||||
models.Domain.objects.external(), "domain", id_kwarg_name="domain_name"
|
||||
)
|
||||
def update_domain_nodeinfo(domain):
|
||||
now = timezone.now()
|
||||
try:
|
||||
nodeinfo = {"status": "ok", "payload": fetch_nodeinfo(domain.name)}
|
||||
except (
|
||||
requests.RequestException,
|
||||
serializers.serializers.ValidationError,
|
||||
ValueError,
|
||||
) as e:
|
||||
nodeinfo = {"status": "error", "error": str(e)}
|
||||
|
||||
service_actor_id = common_utils.recursive_getattr(
|
||||
nodeinfo, "payload.metadata.actorId", permissive=True
|
||||
)
|
||||
try:
|
||||
domain.service_actor = (
|
||||
utils.retrieve_ap_object(
|
||||
service_actor_id,
|
||||
actor=None,
|
||||
queryset=models.Actor,
|
||||
serializer_class=serializers.ActorSerializer,
|
||||
)
|
||||
if service_actor_id
|
||||
else None
|
||||
)
|
||||
except (
|
||||
serializers.serializers.ValidationError,
|
||||
RequestException,
|
||||
exceptions.BlockedActorOrDomain,
|
||||
) as e:
|
||||
logger.warning(
|
||||
"Cannot fetch system actor for domain %s: %s", domain.name, str(e)
|
||||
)
|
||||
domain.nodeinfo_fetch_date = now
|
||||
domain.nodeinfo = nodeinfo
|
||||
domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date", "service_actor"])
|
||||
|
||||
|
||||
@celery.app.task(name="federation.refresh_nodeinfo_known_nodes")
|
||||
def refresh_nodeinfo_known_nodes():
|
||||
"""
|
||||
Trigger a node info refresh on all nodes that weren't refreshed since
|
||||
settings.NODEINFO_REFRESH_DELAY
|
||||
"""
|
||||
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
|
||||
candidates = models.Domain.objects.external().exclude(
|
||||
nodeinfo_fetch_date__gte=limit
|
||||
)
|
||||
names = candidates.values_list("name", flat=True)
|
||||
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
|
||||
for domain_name in names:
|
||||
update_domain_nodeinfo.delay(domain_name=domain_name)
|
||||
|
||||
|
||||
def delete_qs(qs):
|
||||
label = qs.model._meta.label
|
||||
result = qs.delete()
|
||||
related = sum(result[1].values())
|
||||
|
||||
logger.info(
|
||||
"Purged %s %s objects (and %s related entities)", result[0], label, related
|
||||
)
|
||||
|
||||
|
||||
def handle_purge_actors(ids, only=[]):
|
||||
"""
|
||||
Empty only means we purge everything
|
||||
Otherwise, we purge only the requested bits: media
|
||||
"""
|
||||
# purge follows (received emitted)
|
||||
if not only:
|
||||
delete_qs(models.LibraryFollow.objects.filter(target__actor_id__in=ids))
|
||||
delete_qs(models.Follow.objects.filter(actor_id__in=ids))
|
||||
|
||||
# purge audio content
|
||||
if not only or "media" in only:
|
||||
delete_qs(common_models.Attachment.objects.filter(actor__in=ids))
|
||||
delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
|
||||
delete_qs(models.Follow.objects.filter(target_id__in=ids))
|
||||
delete_qs(audio_models.Channel.objects.filter(attributed_to__in=ids))
|
||||
delete_qs(audio_models.Channel.objects.filter(actor__in=ids))
|
||||
delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
|
||||
delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
|
||||
|
||||
# purge remaining activities / deliveries
|
||||
if not only:
|
||||
delete_qs(models.InboxItem.objects.filter(actor_id__in=ids))
|
||||
delete_qs(models.Activity.objects.filter(actor_id__in=ids))
|
||||
|
||||
|
||||
@celery.app.task(name="federation.purge_actors")
|
||||
def purge_actors(ids=[], domains=[], only=[]):
|
||||
actors = models.Actor.objects.filter(
|
||||
Q(id__in=ids) | Q(domain_id__in=domains)
|
||||
).order_by("id")
|
||||
found_ids = list(actors.values_list("id", flat=True))
|
||||
logger.info("Starting purging %s accounts", len(found_ids))
|
||||
handle_purge_actors(ids=found_ids, only=only)
|
||||
|
||||
|
||||
@celery.app.task(name="federation.rotate_actor_key")
|
||||
@celery.require_instance(models.Actor.objects.local(), "actor")
|
||||
def rotate_actor_key(actor):
|
||||
pair = keys.get_key_pair()
|
||||
actor.private_key = pair[0].decode()
|
||||
actor.public_key = pair[1].decode()
|
||||
actor.save(update_fields=["private_key", "public_key"])
|
||||
|
||||
|
||||
@celery.app.task(name="federation.fetch")
|
||||
@transaction.atomic
|
||||
@celery.require_instance(
|
||||
models.Fetch.objects.filter(status="pending").select_related("actor"),
|
||||
"fetch_obj",
|
||||
"fetch_id",
|
||||
)
|
||||
def fetch(fetch_obj):
|
||||
def error(code, **kwargs):
|
||||
fetch_obj.status = "errored"
|
||||
fetch_obj.fetch_date = timezone.now()
|
||||
fetch_obj.detail = {"error_code": code}
|
||||
fetch_obj.detail.update(kwargs)
|
||||
fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
|
||||
|
||||
url = fetch_obj.url
|
||||
mrf_check_url = url
|
||||
if not mrf_check_url.startswith("webfinger://"):
|
||||
payload, updated = mrf.inbox.apply({"id": mrf_check_url})
|
||||
if not payload:
|
||||
return error("blocked", message="Blocked by MRF")
|
||||
|
||||
actor = fetch_obj.actor
|
||||
if settings.FEDERATION_AUTHENTIFY_FETCHES:
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
else:
|
||||
auth = None
|
||||
try:
|
||||
if url.startswith("webfinger://"):
|
||||
# we first grab the corresponding webfinger representation
|
||||
# to get the ActivityPub actor ID
|
||||
webfinger_data = webfinger.get_resource(
|
||||
"acct:" + url.replace("webfinger://", "")
|
||||
)
|
||||
url = webfinger.get_ap_url(webfinger_data["links"])
|
||||
if not url:
|
||||
return error("webfinger", message="Invalid or missing webfinger data")
|
||||
payload, updated = mrf.inbox.apply({"id": url})
|
||||
if not payload:
|
||||
return error("blocked", message="Blocked by MRF")
|
||||
response = session.get_session().get(
|
||||
auth=auth,
|
||||
url=url,
|
||||
headers={"Accept": "application/activity+json"},
|
||||
)
|
||||
logger.debug("Remote answered with %s: %s", response.status_code, response.text)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error(
|
||||
"http",
|
||||
status_code=e.response.status_code if e.response else None,
|
||||
message=e.response.text,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
return error("timeout")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return error("connection", message=str(e))
|
||||
except requests.RequestException as e:
|
||||
return error("request", message=str(e))
|
||||
except Exception as e:
|
||||
return error("unhandled", message=str(e))
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
# we attempt to extract a <link rel=alternate> that points
|
||||
# to an activity pub resource, if possible, and retry with this URL
|
||||
alternate_url = utils.find_alternate(response.text)
|
||||
if alternate_url:
|
||||
fetch_obj.url = alternate_url
|
||||
fetch_obj.save(update_fields=["url"])
|
||||
return fetch(fetch_id=fetch_obj.pk)
|
||||
return error("invalid_json")
|
||||
|
||||
payload, updated = mrf.inbox.apply(payload)
|
||||
if not payload:
|
||||
return error("blocked", message="Blocked by MRF")
|
||||
|
||||
try:
|
||||
doc = jsonld.expand(payload)
|
||||
except ValueError:
|
||||
return error("invalid_jsonld")
|
||||
|
||||
try:
|
||||
type = doc.get("@type", [])[0]
|
||||
except IndexError:
|
||||
return error("missing_jsonld_type")
|
||||
try:
|
||||
serializer_classes = fetch_obj.serializers[type]
|
||||
model = serializer_classes[0].Meta.model
|
||||
except (KeyError, AttributeError):
|
||||
fetch_obj.status = "skipped"
|
||||
fetch_obj.fetch_date = timezone.now()
|
||||
fetch_obj.detail = {"reason": "unhandled_type", "type": type}
|
||||
return fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
|
||||
try:
|
||||
id = doc.get("@id")
|
||||
except IndexError:
|
||||
existing = None
|
||||
else:
|
||||
existing = model.objects.filter(fid=id).first()
|
||||
|
||||
serializer = None
|
||||
for serializer_class in serializer_classes:
|
||||
serializer = serializer_class(
|
||||
existing, data=payload, context={"fetch_actor": actor}
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if serializer.errors:
|
||||
return error("validation", validation_errors=serializer.errors)
|
||||
try:
|
||||
obj = serializer.save()
|
||||
except Exception as e:
|
||||
error("save", message=str(e))
|
||||
raise
|
||||
|
||||
# special case for channels
|
||||
# when obj is an actor, we check if the actor has a channel associated with it
|
||||
# if it is the case, we consider the fetch obj to be a channel instead
|
||||
# and also trigger a fetch on the channel outbox
|
||||
if isinstance(obj, models.Actor) and obj.get_channel():
|
||||
obj = obj.get_channel()
|
||||
if obj.actor.outbox_url:
|
||||
try:
|
||||
# first page fetch is synchronous, so that at least some data is available
|
||||
# in the UI after subscription
|
||||
result = fetch_collection(
|
||||
obj.actor.outbox_url,
|
||||
channel_id=obj.pk,
|
||||
max_pages=1,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error while fetching actor outbox: %s", obj.actor.outbox_url
|
||||
)
|
||||
else:
|
||||
if result.get("next_page"):
|
||||
# additional pages are fetched in the background
|
||||
result = fetch_collection.delay(
|
||||
result["next_page"],
|
||||
channel_id=obj.pk,
|
||||
max_pages=settings.FEDERATION_COLLECTION_MAX_PAGES - 1,
|
||||
is_page=True,
|
||||
)
|
||||
|
||||
fetch_obj.object = obj
|
||||
fetch_obj.status = "finished"
|
||||
fetch_obj.fetch_date = timezone.now()
|
||||
return fetch_obj.save(
|
||||
update_fields=["fetch_date", "status", "object_id", "object_content_type"]
|
||||
)
|
||||
|
||||
|
||||
class PreserveSomeDataCollector(Collector):
|
||||
"""
|
||||
We need to delete everything related to an actor. Well… Almost everything.
|
||||
But definitely not the Delete Activity we send to announce the actor is deleted.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.creation_date = timezone.now()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def related_objects(self, related, *args, **kwargs):
|
||||
qs = super().related_objects(related, *args, **kwargs)
|
||||
# We can only exclude the actions if these fields are available, most likely its a
|
||||
# model.Activity than
|
||||
if hasattr(related, "type") and hasattr(related, "creation_date"):
|
||||
# exclude the delete activity can be broadcasted properly
|
||||
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@celery.app.task(name="federation.remove_actor")
|
||||
@transaction.atomic
|
||||
@celery.require_instance(
|
||||
models.Actor.objects.all(),
|
||||
"actor",
|
||||
)
|
||||
def remove_actor(actor):
|
||||
# Then we broadcast the info over federation. We do this *before* deleting objects
|
||||
# associated with the actor, otherwise follows are removed and we don't know where
|
||||
# to broadcast
|
||||
logger.info("Broadcasting deletion to federation…")
|
||||
collector = PreserveSomeDataCollector(using="default")
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
|
||||
)
|
||||
|
||||
# then we delete any object associated with the actor object, but *not* the actor
|
||||
# itself. We keep it for auditability and sending the Delete ActivityPub message
|
||||
logger.info(
|
||||
"Prepare deletion of objects associated with account %s…",
|
||||
actor.preferred_username,
|
||||
)
|
||||
collector.collect([actor])
|
||||
for model, instances in collector.data.items():
|
||||
if issubclass(model, actor.__class__):
|
||||
# we skip deletion of the actor itself
|
||||
continue
|
||||
|
||||
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
|
||||
logger.info(
|
||||
"Deleting %s objects associated with account %s…",
|
||||
len(instances),
|
||||
actor.preferred_username,
|
||||
)
|
||||
to_delete.delete()
|
||||
|
||||
# Finally, we update the actor itself and mark it as removed
|
||||
logger.info("Marking actor as Tombsone…")
|
||||
actor.type = "Tombstone"
|
||||
actor.name = None
|
||||
actor.summary = None
|
||||
actor.save(update_fields=["type", "name", "summary"])
|
||||
|
||||
|
||||
COLLECTION_ACTIVITY_SERIALIZERS = [
|
||||
(
|
||||
{"type": "Create", "object.type": "Audio"},
|
||||
serializers.ChannelCreateUploadSerializer,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def match_serializer(payload, conf):
|
||||
return [
|
||||
serializer_class
|
||||
for route, serializer_class in conf
|
||||
if activity.match_route(route, payload)
|
||||
]
|
||||
|
||||
|
||||
@celery.app.task(name="federation.fetch_collection")
|
||||
@celery.require_instance(
|
||||
audio_models.Channel.objects.all(),
|
||||
"channel",
|
||||
allow_null=True,
|
||||
)
|
||||
def fetch_collection(url, max_pages, channel, is_page=False):
|
||||
actor = actors.get_service_actor()
|
||||
results = {
|
||||
"items": [],
|
||||
"skipped": 0,
|
||||
"errored": 0,
|
||||
"seen": 0,
|
||||
"total": 0,
|
||||
}
|
||||
if is_page:
|
||||
# starting immediately from a page, no need to fetch the wrapping collection
|
||||
logger.debug("Fetch collection page immediately at %s", url)
|
||||
results["next_page"] = url
|
||||
else:
|
||||
logger.debug("Fetching collection object at %s", url)
|
||||
collection = utils.retrieve_ap_object(
|
||||
url,
|
||||
actor=actor,
|
||||
serializer_class=serializers.PaginatedCollectionSerializer,
|
||||
)
|
||||
results["next_page"] = collection["first"]
|
||||
results["total"] = collection.get("totalItems")
|
||||
|
||||
seen_pages = 0
|
||||
context = {}
|
||||
if channel:
|
||||
context["channel"] = channel
|
||||
|
||||
for i in range(max_pages):
|
||||
page_url = results["next_page"]
|
||||
logger.debug("Handling page %s on max %s, at %s", i + 1, max_pages, page_url)
|
||||
page = utils.retrieve_ap_object(
|
||||
page_url,
|
||||
actor=actor,
|
||||
serializer_class=None,
|
||||
)
|
||||
try:
|
||||
items = page["orderedItems"]
|
||||
except KeyError:
|
||||
try:
|
||||
items = page["items"]
|
||||
except KeyError:
|
||||
logger.error("Invalid collection page at %s", page_url)
|
||||
break
|
||||
|
||||
for item in items:
|
||||
results["seen"] += 1
|
||||
|
||||
matching_serializer = match_serializer(
|
||||
item, COLLECTION_ACTIVITY_SERIALIZERS
|
||||
)
|
||||
if not matching_serializer:
|
||||
results["skipped"] += 1
|
||||
logger.debug("Skipping unhandled activity %s", item.get("type"))
|
||||
continue
|
||||
|
||||
s = matching_serializer[0](data=item, context=context)
|
||||
if not s.is_valid():
|
||||
logger.warn("Skipping invalid activity: %s", s.errors)
|
||||
results["errored"] += 1
|
||||
continue
|
||||
|
||||
results["items"].append(s.save())
|
||||
|
||||
seen_pages += 1
|
||||
results["next_page"] = page.get("next", None) or None
|
||||
if not results["next_page"]:
|
||||
logger.debug("No more pages to fetch")
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Finished fetch of collection pages at %s. Results:\n"
|
||||
" Total in collection: %s\n"
|
||||
" Seen: %s\n"
|
||||
" Handled: %s\n"
|
||||
" Skipped: %s\n"
|
||||
" Errored: %s",
|
||||
url,
|
||||
results.get("total"),
|
||||
results["seen"],
|
||||
len(results["items"]),
|
||||
results["skipped"],
|
||||
results["errored"],
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@celery.app.task(name="federation.check_all_remote_instance_availability")
|
||||
def check_all_remote_instance_availability():
|
||||
domains = models.Domain.objects.all().prefetch_related()
|
||||
for domain in domains:
|
||||
if domain.name == settings.FUNQUAIL_HOSTNAME:
|
||||
# No need to check the instance itself: Its always reachable
|
||||
domain.reachable = True
|
||||
domain.last_successful_contact = timezone.now()
|
||||
else:
|
||||
check_single_remote_instance_availability(domain)
|
||||
|
||||
|
||||
@celery.app.task(name="federation.check_single_remote_instance_availability")
|
||||
def check_single_remote_instance_availability(domain):
|
||||
try:
|
||||
nodeinfo = fetch_nodeinfo(domain.name)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"Domain {domain.name} could not be reached because of the following error : {e}. \
|
||||
Setting domain as unreachable."
|
||||
)
|
||||
domain.reachable = False
|
||||
domain.save()
|
||||
return domain.reachable
|
||||
|
||||
if "version" in nodeinfo.keys():
|
||||
domain.reachable = True
|
||||
domain.last_successful_contact = timezone.now()
|
||||
domain.save()
|
||||
return domain.reachable
|
||||
else:
|
||||
logger.info(
|
||||
f"Domain {domain.name} is not reachable at the moment. Setting domain as unreachable."
|
||||
)
|
||||
domain.reachable = False
|
||||
domain.save()
|
||||
return domain.reachable
|
||||
28
api/funkwhale_api/federation/urls.py
Normal file
28
api/funkwhale_api/federation/urls.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from django.conf.urls import include, url
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.SimpleRouter(trailing_slash=False)
|
||||
music_router = routers.SimpleRouter(trailing_slash=False)
|
||||
index_router = routers.SimpleRouter(trailing_slash=False)
|
||||
|
||||
router.register(r"federation/shared", views.SharedViewSet, "shared")
|
||||
router.register(r"federation/actors", views.ActorViewSet, "actors")
|
||||
router.register(r"federation/edits", views.EditViewSet, "edits")
|
||||
router.register(r"federation/reports", views.ReportViewSet, "reports")
|
||||
router.register(r".well-known", views.WellKnownViewSet, "well-known")
|
||||
|
||||
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
|
||||
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
|
||||
music_router.register(r"artists", views.MusicArtistViewSet, "artists")
|
||||
music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
|
||||
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
|
||||
|
||||
|
||||
index_router.register(r"index", views.IndexViewSet, "index")
|
||||
|
||||
urlpatterns = router.urls + [
|
||||
url("federation/music/", include((music_router.urls, "music"), namespace="music")),
|
||||
url("federation/", include((index_router.urls, "index"), namespace="index")),
|
||||
]
|
||||
290
api/funkwhale_api/federation/utils.py
Normal file
290
api/funkwhale_api/federation/utils.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import html.parser
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import CharField, Q, Value
|
||||
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.moderation import mrf
|
||||
|
||||
from . import exceptions, signing
|
||||
|
||||
|
||||
def full_url(path):
|
||||
"""
|
||||
Given a relative path, return a full url usable for federation purpose
|
||||
"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
root = settings.FUNQUAIL_URL
|
||||
if path.startswith("/") and root.endswith("/"):
|
||||
return root + path[1:]
|
||||
elif not path.startswith("/") and not root.endswith("/"):
|
||||
return root + "/" + path
|
||||
else:
|
||||
return root + path
|
||||
|
||||
|
||||
def clean_wsgi_headers(raw_headers):
|
||||
"""
|
||||
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
|
||||
"""
|
||||
cleaned = {}
|
||||
non_prefixed = ["content_type", "content_length"]
|
||||
for raw_header, value in raw_headers.items():
|
||||
h = raw_header.lower()
|
||||
if not h.startswith("http_") and h not in non_prefixed:
|
||||
continue
|
||||
|
||||
words = h.replace("http_", "", 1).split("_")
|
||||
cleaned_header = "-".join([w.capitalize() for w in words])
|
||||
cleaned[cleaned_header] = value
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def slugify_username(username):
|
||||
"""
|
||||
Given a username such as "hello M. world", returns a username
|
||||
suitable for federation purpose (hello_M_world).
|
||||
|
||||
Preserves the original case.
|
||||
|
||||
Code is borrowed from django's slugify function.
|
||||
"""
|
||||
|
||||
value = str(username)
|
||||
value = (
|
||||
unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
)
|
||||
value = re.sub(r"[^\w\s-]", "", value).strip()
|
||||
return re.sub(r"[-\s]+", "_", value)
|
||||
|
||||
|
||||
def retrieve_ap_object(
|
||||
fid,
|
||||
actor,
|
||||
serializer_class=None,
|
||||
queryset=None,
|
||||
apply_instance_policies=True,
|
||||
):
|
||||
# we have a duplicate check here because it's less expensive to do those checks
|
||||
# twice than to trigger a HTTP request
|
||||
payload, updated = mrf.inbox.apply({"id": fid})
|
||||
if not payload:
|
||||
raise exceptions.BlockedActorOrDomain()
|
||||
if queryset:
|
||||
try:
|
||||
# queryset can also be a Model class
|
||||
existing = queryset.filter(fid=fid).first()
|
||||
except AttributeError:
|
||||
existing = queryset.objects.filter(fid=fid).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
auth = (
|
||||
None if not actor else signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
)
|
||||
response = session.get_session().get(
|
||||
fid,
|
||||
auth=auth,
|
||||
headers={
|
||||
"Accept": "application/activity+json",
|
||||
"Content-Type": "application/activity+json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# we match against mrf here again, because new data may yield different
|
||||
# results
|
||||
data, updated = mrf.inbox.apply(data)
|
||||
if not data:
|
||||
raise exceptions.BlockedActorOrDomain()
|
||||
|
||||
if not serializer_class:
|
||||
return data
|
||||
serializer = serializer_class(data=data, context={"fetch_actor": actor})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
return serializer.save()
|
||||
except NotImplementedError:
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def get_domain_query_from_url(domain, url_field="fid"):
|
||||
"""
|
||||
Given a domain name and a field, will return a Q() object
|
||||
to match objects that have this domain in the given field.
|
||||
"""
|
||||
|
||||
query = Q(**{f"{url_field}__startswith": f"http://{domain}/"})
|
||||
query = query | Q(**{f"{url_field}__startswith": f"https://{domain}/"})
|
||||
return query
|
||||
|
||||
|
||||
def local_qs(queryset, url_field="fid", include=True):
|
||||
query = get_domain_query_from_url(
|
||||
domain=settings.FEDERATION_HOSTNAME, url_field=url_field
|
||||
)
|
||||
if not include:
|
||||
query = ~query
|
||||
return queryset.filter(query)
|
||||
|
||||
|
||||
def is_local(url) -> bool:
|
||||
if not url:
|
||||
return True
|
||||
|
||||
d = settings.FEDERATION_HOSTNAME
|
||||
return url.startswith(f"http://{d}/") or url.startswith(f"https://{d}/")
|
||||
|
||||
|
||||
def get_actor_data_from_username(username):
|
||||
parts = username.split("@")
|
||||
|
||||
return {
|
||||
"username": parts[0],
|
||||
"domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME,
|
||||
}
|
||||
|
||||
|
||||
def get_actor_from_username_data_query(field, data):
|
||||
if not data:
|
||||
return Q(**{field: None})
|
||||
if field:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__preferred_username__iexact": data["username"],
|
||||
f"{field}__domain__name__iexact": data["domain"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
return Q(
|
||||
**{
|
||||
"preferred_username__iexact": data["username"],
|
||||
"domain__name__iexact": data["domain"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StopParsing(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AlternateLinkParser(html.parser.HTMLParser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.result = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag != "link":
|
||||
return
|
||||
|
||||
attrs_dict = dict(attrs)
|
||||
if attrs_dict.get("rel") == "alternate" and attrs_dict.get(
|
||||
"type", "application/activity+json"
|
||||
):
|
||||
self.result = attrs_dict.get("href")
|
||||
raise StopParsing()
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "head":
|
||||
raise StopParsing()
|
||||
|
||||
|
||||
def find_alternate(response_text):
|
||||
if not response_text:
|
||||
return
|
||||
|
||||
parser = AlternateLinkParser()
|
||||
try:
|
||||
parser.feed(response_text)
|
||||
except StopParsing:
|
||||
return parser.result
|
||||
|
||||
|
||||
def should_redirect_ap_to_html(accept_header, default=True):
|
||||
if not accept_header:
|
||||
return False
|
||||
|
||||
redirect_headers = [
|
||||
"text/html",
|
||||
]
|
||||
no_redirect_headers = [
|
||||
"application/json",
|
||||
"application/activity+json",
|
||||
"application/ld+json",
|
||||
]
|
||||
|
||||
parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
|
||||
for ct in parsed_header:
|
||||
if ct in redirect_headers:
|
||||
return True
|
||||
if ct in no_redirect_headers:
|
||||
return False
|
||||
|
||||
return default
|
||||
|
||||
|
||||
FID_MODEL_LABELS = [
|
||||
"music.Artist",
|
||||
"music.Album",
|
||||
"music.Track",
|
||||
"music.Library",
|
||||
"music.Upload",
|
||||
"federation.Actor",
|
||||
]
|
||||
|
||||
|
||||
def get_object_by_fid(fid, local=None):
|
||||
if local:
|
||||
parsed = urllib.parse.urlparse(fid)
|
||||
if parsed.netloc != settings.FEDERATION_HOSTNAME:
|
||||
raise ObjectDoesNotExist()
|
||||
|
||||
models = [apps.get_model(*l.split(".")) for l in FID_MODEL_LABELS]
|
||||
|
||||
def get_qs(model):
|
||||
return (
|
||||
model.objects.all()
|
||||
.filter(fid=fid)
|
||||
.annotate(__type=Value(model._meta.label, output_field=CharField()))
|
||||
.values("fid", "__type")
|
||||
)
|
||||
|
||||
qs = get_qs(models[0])
|
||||
for m in models[1:]:
|
||||
qs = qs.union(get_qs(m))
|
||||
|
||||
result = qs.order_by("fid").first()
|
||||
|
||||
if not result:
|
||||
raise ObjectDoesNotExist()
|
||||
model = apps.get_model(*result["__type"].split("."))
|
||||
instance = model.objects.get(fid=fid)
|
||||
if model._meta.label == "federation.Actor":
|
||||
channel = instance.get_channel()
|
||||
if channel:
|
||||
return channel
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def can_manage(obj_owner, actor):
|
||||
if not obj_owner:
|
||||
return False
|
||||
|
||||
if not actor:
|
||||
return False
|
||||
|
||||
if obj_owner == actor:
|
||||
return True
|
||||
if obj_owner.domain.service_actor == actor:
|
||||
return True
|
||||
|
||||
return False
|
||||
529
api/funkwhale_api/federation/views.py
Normal file
529
api/funkwhale_api/federation/views.py
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core import paginator
|
||||
from django.db.models import Prefetch
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from rest_framework import exceptions, mixins, permissions, response, viewsets
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.moderation import models as moderation_models
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import utils as music_utils
|
||||
|
||||
from . import (
|
||||
activity,
|
||||
actors,
|
||||
authentication,
|
||||
models,
|
||||
renderers,
|
||||
serializers,
|
||||
utils,
|
||||
webfinger,
|
||||
)
|
||||
|
||||
|
||||
def redirect_to_html(public_url):
|
||||
response = HttpResponse(status=302)
|
||||
response["Location"] = common_utils.join_url(settings.FUNQUAIL_URL, public_url)
|
||||
return response
|
||||
|
||||
|
||||
def get_collection_response(
|
||||
conf, querystring, collection_serializer, page_access_check=None
|
||||
):
|
||||
page = querystring.get("page")
|
||||
if page is None:
|
||||
data = collection_serializer.data
|
||||
else:
|
||||
if page_access_check and not page_access_check():
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"You do not have access to this resource"
|
||||
)
|
||||
try:
|
||||
page_number = int(page)
|
||||
except Exception:
|
||||
return response.Response({"page": ["Invalid page number"]}, status=400)
|
||||
conf["page_size"] = preferences.get("federation__collection_page_size")
|
||||
p = paginator.Paginator(conf["items"], conf["page_size"])
|
||||
try:
|
||||
page = p.page(page_number)
|
||||
conf["page"] = page
|
||||
serializer = serializers.CollectionPageSerializer(conf)
|
||||
data = serializer.data
|
||||
except paginator.EmptyPage:
|
||||
return response.Response(status=404)
|
||||
|
||||
return response.Response(data)
|
||||
|
||||
|
||||
class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
|
||||
if not allow_list_enabled:
|
||||
return True
|
||||
return bool(request.actor)
|
||||
|
||||
|
||||
class FederationMixin:
|
||||
permission_classes = [AuthenticatedIfAllowListEnabled]
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not preferences.get("federation__enabled"):
|
||||
return HttpResponse(status=405)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class SharedViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
|
||||
@action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
|
||||
)
|
||||
def inbox(self, request, *args, **kwargs):
|
||||
if request.method.lower() == "post" and request.actor is None:
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"You need a valid signature to send an activity"
|
||||
)
|
||||
if request.method.lower() == "post":
|
||||
activity.receive(activity=request.data, on_behalf_of=request.actor)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
|
||||
class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
lookup_field = "preferred_username"
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = (
|
||||
models.Actor.objects.local()
|
||||
.select_related("user", "channel__artist", "channel__attributed_to")
|
||||
.prefetch_related("channel__artist__tagged_items__tag")
|
||||
)
|
||||
serializer_class = serializers.ActorSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
return queryset.exclude(channel__attributed_to=actors.get_service_actor())
|
||||
|
||||
def get_permissions(self):
|
||||
# cf #1999 it must be possible to fetch actors without being authenticated
|
||||
# otherwise we end up in a loop
|
||||
if self.action == "retrieve":
|
||||
return []
|
||||
return super().get_permissions()
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
if instance.get_channel():
|
||||
return redirect_to_html(instance.channel.get_absolute_url())
|
||||
return redirect_to_html(instance.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
@action(
|
||||
methods=["get", "post"],
|
||||
detail=True,
|
||||
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
|
||||
)
|
||||
def inbox(self, request, *args, **kwargs):
|
||||
inbox_actor = self.get_object()
|
||||
if request.method.lower() == "post" and request.actor is None:
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"You need a valid signature to send an activity"
|
||||
)
|
||||
if request.method.lower() == "post":
|
||||
activity.receive(
|
||||
activity=request.data,
|
||||
on_behalf_of=request.actor,
|
||||
inbox_actor=inbox_actor,
|
||||
)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@action(methods=["get", "post"], detail=True)
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
actor = self.get_object()
|
||||
channel = actor.get_channel()
|
||||
if channel:
|
||||
return self.get_channel_outbox_response(request, channel)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
def get_channel_outbox_response(self, request, channel):
|
||||
conf = {
|
||||
"id": channel.actor.outbox_url,
|
||||
"actor": channel.actor,
|
||||
"items": channel.library.uploads.for_federation()
|
||||
.order_by("-creation_date")
|
||||
.prefetch_related("library__channel__actor", "track__artist"),
|
||||
"item_serializer": serializers.ChannelCreateUploadSerializer,
|
||||
}
|
||||
return get_collection_response(
|
||||
conf=conf,
|
||||
querystring=request.GET,
|
||||
collection_serializer=serializers.ChannelOutboxSerializer(channel),
|
||||
)
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
def followers(self, request, *args, **kwargs):
|
||||
self.get_object()
|
||||
# XXX to implement
|
||||
return response.Response({})
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
def following(self, request, *args, **kwargs):
|
||||
self.get_object()
|
||||
# XXX to implement
|
||||
return response.Response({})
|
||||
|
||||
|
||||
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
lookup_field = "uuid"
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
# queryset = common_models.Mutation.objects.local().select_related()
|
||||
# serializer_class = serializers.ActorSerializer
|
||||
|
||||
|
||||
class ReportViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
lookup_field = "uuid"
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = moderation_models.Report.objects.none()
|
||||
|
||||
|
||||
class WellKnownViewSet(viewsets.GenericViewSet):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def nodeinfo(self, request, *args, **kwargs):
|
||||
data = {
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
"href": utils.full_url(reverse("api:v1:instance:nodeinfo-2.0")),
|
||||
}
|
||||
]
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def webfinger(self, request, *args, **kwargs):
|
||||
if not preferences.get("federation__enabled"):
|
||||
return HttpResponse(status=405)
|
||||
try:
|
||||
resource_type, resource = webfinger.clean_resource(request.GET["resource"])
|
||||
cleaner = getattr(webfinger, f"clean_{resource_type}")
|
||||
result = cleaner(resource)
|
||||
handler = getattr(self, f"handler_{resource_type}")
|
||||
data = handler(result)
|
||||
except forms.ValidationError as e:
|
||||
return response.Response({"errors": {"resource": e.message}}, status=400)
|
||||
except KeyError:
|
||||
return response.Response(
|
||||
{"errors": {"resource": "This field is required"}}, status=400
|
||||
)
|
||||
|
||||
return response.Response(data)
|
||||
|
||||
def handler_acct(self, clean_result):
|
||||
username, hostname = clean_result
|
||||
|
||||
try:
|
||||
actor = models.Actor.objects.local().get(preferred_username=username)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise forms.ValidationError("Invalid username")
|
||||
|
||||
return serializers.ActorWebfingerSerializer(actor).data
|
||||
|
||||
|
||||
def has_library_access(request, library):
|
||||
if library.privacy_level == "everyone":
|
||||
return True
|
||||
if request.user.is_authenticated and request.user.is_superuser:
|
||||
return True
|
||||
|
||||
try:
|
||||
actor = request.actor
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
return library.received_follows.filter(actor=actor, approved=True).exists()
|
||||
|
||||
|
||||
class MusicLibraryViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
serializer_class = serializers.LibrarySerializer
|
||||
queryset = (
|
||||
music_models.Library.objects.all()
|
||||
.local()
|
||||
.select_related("actor")
|
||||
.filter(channel=None)
|
||||
)
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
lb = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(lb.get_absolute_url())
|
||||
conf = {
|
||||
"id": lb.get_federation_id(),
|
||||
"actor": lb.actor,
|
||||
"name": lb.name,
|
||||
"summary": lb.description,
|
||||
"items": lb.uploads.for_federation()
|
||||
.order_by("-creation_date")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"track",
|
||||
queryset=music_models.Track.objects.select_related(
|
||||
"album__artist__attributed_to",
|
||||
"artist__attributed_to",
|
||||
"artist__attachment_cover",
|
||||
"attachment_cover",
|
||||
"album__attributed_to",
|
||||
"attributed_to",
|
||||
"album__attachment_cover",
|
||||
"album__artist__attachment_cover",
|
||||
"description",
|
||||
).prefetch_related(
|
||||
"tagged_items__tag",
|
||||
"album__tagged_items__tag",
|
||||
"album__artist__tagged_items__tag",
|
||||
"artist__tagged_items__tag",
|
||||
"artist__description",
|
||||
"album__description",
|
||||
),
|
||||
)
|
||||
),
|
||||
"item_serializer": serializers.UploadSerializer,
|
||||
}
|
||||
|
||||
return get_collection_response(
|
||||
conf=conf,
|
||||
querystring=request.GET,
|
||||
collection_serializer=serializers.LibrarySerializer(lb),
|
||||
page_access_check=lambda: has_library_access(request, lb),
|
||||
)
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
def followers(self, request, *args, **kwargs):
|
||||
self.get_object()
|
||||
# XXX Implement this
|
||||
return response.Response({})
|
||||
|
||||
|
||||
class MusicUploadViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = music_models.Upload.objects.local().select_related(
|
||||
"library__actor",
|
||||
"track__artist",
|
||||
"track__album__artist",
|
||||
"track__description",
|
||||
"track__album__attachment_cover",
|
||||
"track__album__artist__attachment_cover",
|
||||
"track__artist__attachment_cover",
|
||||
"track__attachment_cover",
|
||||
)
|
||||
serializer_class = serializers.UploadSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(instance.track.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
actor = music_utils.get_actor_from_request(self.request)
|
||||
return queryset.playable_by(actor)
|
||||
|
||||
def get_serializer(self, obj):
|
||||
if obj.library.get_channel():
|
||||
return serializers.ChannelUploadSerializer(obj)
|
||||
return super().get_serializer(obj)
|
||||
|
||||
@action(
|
||||
methods=["get"],
|
||||
detail=True,
|
||||
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
|
||||
)
|
||||
def activity(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
serializer = serializers.ChannelCreateUploadSerializer(object)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class MusicArtistViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = music_models.Artist.objects.local().select_related(
|
||||
"description", "attachment_cover"
|
||||
)
|
||||
serializer_class = serializers.ArtistSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(instance.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class MusicAlbumViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = music_models.Album.objects.local().select_related(
|
||||
"artist__description", "description", "artist__attachment_cover"
|
||||
)
|
||||
serializer_class = serializers.AlbumSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(instance.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class MusicTrackViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = music_models.Track.objects.local().select_related(
|
||||
"album__artist",
|
||||
"album__description",
|
||||
"artist__description",
|
||||
"description",
|
||||
"attachment_cover",
|
||||
"album__artist__attachment_cover",
|
||||
"album__attachment_cover",
|
||||
"artist__attachment_cover",
|
||||
)
|
||||
serializer_class = serializers.TrackSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(instance.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class ChannelViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = music_models.Artist.objects.local().select_related(
|
||||
"description", "attachment_cover"
|
||||
)
|
||||
serializer_class = serializers.ArtistSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
|
||||
return redirect_to_html(instance.get_absolute_url())
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not preferences.get("federation__public_index"):
|
||||
return HttpResponse(status=405)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@action(
|
||||
methods=["get"],
|
||||
detail=False,
|
||||
)
|
||||
def libraries(self, request, *args, **kwargs):
|
||||
libraries = (
|
||||
music_models.Library.objects.local()
|
||||
.filter(channel=None, privacy_level="everyone")
|
||||
.prefetch_related("actor")
|
||||
.order_by("creation_date")
|
||||
)
|
||||
conf = {
|
||||
"id": federation_utils.full_url(
|
||||
reverse("federation:index:index-libraries")
|
||||
),
|
||||
"items": libraries,
|
||||
"item_serializer": serializers.LibrarySerializer,
|
||||
"page_size": 100,
|
||||
"actor": None,
|
||||
}
|
||||
return get_collection_response(
|
||||
conf=conf,
|
||||
querystring=request.GET,
|
||||
collection_serializer=serializers.IndexSerializer(conf),
|
||||
)
|
||||
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@action(
|
||||
methods=["get"],
|
||||
detail=False,
|
||||
)
|
||||
def channels(self, request, *args, **kwargs):
|
||||
actors = (
|
||||
models.Actor.objects.local()
|
||||
.exclude(channel=None)
|
||||
.order_by("channel__creation_date")
|
||||
.prefetch_related(
|
||||
"channel__attributed_to",
|
||||
"channel__artist",
|
||||
"channel__artist__description",
|
||||
"channel__artist__attachment_cover",
|
||||
)
|
||||
)
|
||||
conf = {
|
||||
"id": federation_utils.full_url(reverse("federation:index:index-channels")),
|
||||
"items": actors,
|
||||
"item_serializer": serializers.ActorSerializer,
|
||||
"page_size": 100,
|
||||
"actor": None,
|
||||
}
|
||||
return get_collection_response(
|
||||
conf=conf,
|
||||
querystring=request.GET,
|
||||
collection_serializer=serializers.IndexSerializer(conf),
|
||||
)
|
||||
|
||||
return response.Response({}, status=200)
|
||||
57
api/funkwhale_api/federation/webfinger.py
Normal file
57
api/funkwhale_api/federation/webfinger.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from funkwhale_api.common import session
|
||||
|
||||
from . import serializers
|
||||
|
||||
VALID_RESOURCE_TYPES = ["acct"]
|
||||
|
||||
|
||||
def clean_resource(resource_string):
|
||||
if not resource_string:
|
||||
raise forms.ValidationError("Invalid resource string")
|
||||
|
||||
try:
|
||||
resource_type, resource = resource_string.split(":", 1)
|
||||
except ValueError:
|
||||
raise forms.ValidationError("Missing webfinger resource type")
|
||||
|
||||
if resource_type not in VALID_RESOURCE_TYPES:
|
||||
raise forms.ValidationError("Invalid webfinger resource type")
|
||||
|
||||
return resource_type, resource
|
||||
|
||||
|
||||
def clean_acct(acct_string, ensure_local=True):
|
||||
try:
|
||||
username, hostname = acct_string.split("@")
|
||||
except ValueError:
|
||||
raise forms.ValidationError("Invalid format")
|
||||
|
||||
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError(f"Invalid hostname {hostname}")
|
||||
|
||||
return username, hostname
|
||||
|
||||
|
||||
def get_resource(resource_string):
|
||||
resource_type, resource = clean_resource(resource_string)
|
||||
username, hostname = clean_acct(resource, ensure_local=False)
|
||||
url = "https://{}/.well-known/webfinger?resource={}".format(
|
||||
hostname, resource_string
|
||||
)
|
||||
response = session.get_session().get(url)
|
||||
response.raise_for_status()
|
||||
serializer = serializers.ActorWebfingerSerializer(data=response.json())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def get_ap_url(links):
|
||||
for link in links:
|
||||
if (
|
||||
link.get("rel") == "self"
|
||||
and link.get("type") == "application/activity+json"
|
||||
):
|
||||
return link["href"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue