bookwyrm-mastodon/bookwyrm/models/activitypub_mixin/activitypub_mixin.py

250 lines
8.7 KiB
Python
Raw Normal View History

2021-02-04 16:21:55 -05:00
''' activitypub model functionality '''
2021-02-04 15:36:39 -05:00
from functools import reduce
import json
import operator
import requests
from django.apps import apps
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from django.utils.http import http_date
from bookwyrm import activitypub
from bookwyrm.settings import USER_AGENT
from bookwyrm.signatures import make_signature, make_digest
from bookwyrm.tasks import app
from .fields import ImageField, ManyToManyField
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
reverse_unfurl = False
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
if isinstance(field, ImageField):
self.image_fields.append(field)
elif isinstance(field, ManyToManyField):
self.many_to_many_fields.append(field)
else:
self.simple_fields.append(field)
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
super().__init__(*args, **kwargs)
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
filters = []
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
continue
value = data.get(field.get_activitypub_field())
if not value:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
# kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
if not filters:
# if there are no deduplication fields, it will match the first
# item no matter what. this shouldn't happen but just in case.
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
objects = objects.select_subclasses()
# an OR operation on all the match fields
match = objects.filter(
reduce(
operator.or_, (Q(**f) for f in filters)
)
)
# there OUGHT to be only one match
return match.first()
def broadcast(self, activity, sender, software=None):
''' send out an activity '''
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
self.get_recipients(software=software)
)
def get_recipients(self, software=None):
''' figure out which inbox urls to post to '''
# first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, 'privacy') else 'public'
# is this activity owned by a user (statuses, lists, shelves), or is it
# general to the instance (like books)
user = self.user if hasattr(self, 'user') else None
if not user and self.__model__ == 'user':
# or maybe the thing itself is a user
user = self
# find anyone who's tagged in a status, for example
mentions = self.mention_users if hasattr(self, 'mention_users') else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or []]
# unless it's a dm, all the followers should receive the activity
if privacy != 'direct':
user_model = apps.get_model('bookwyrm.User', require_ready=True)
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
queryset = user_model.objects.filter(
bookwyrm_user=(software == 'bookwyrm')
)
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
shared_inboxes = queryset.filter(
shared_inbox__isnull=False
).values_list('shared_inbox', flat=True).distinct()
# but not everyone has a shared inbox
inboxes = queryset.filter(
shared_inboxes__isnull=True
).values_list('inbox', flat=True)
recipients += list(shared_inboxes) + list(inboxes)
return recipients
def to_activity(self):
''' convert from a model to an activity '''
activity = generate_activity(self)
return self.activity_serializer(**activity).serialize()
def generate_activity(obj):
''' go through the fields on an object '''
activity = {}
for field in obj.activity_fields:
field.set_activity_from_field(activity, obj)
if hasattr(obj, 'serialize_reverse_fields'):
# for example, editions of a work
for model_field_name, activity_field_name, sort_field in \
obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field, sort_field)
if not activity.get('id'):
activity['id'] = obj.get_remote_id()
return activity
def unfurl_related_field(related_field, sort_field=None):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.order_by(
sort_field).all()]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
@app.task
def broadcast_task(sender_id, activity, recipients):
''' the celery task for broadcast '''
user_model = apps.get_model('bookwyrm.User', require_ready=True)
sender = user_model.objects.get(id=sender_id)
errors = []
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e:
errors.append({
'error': str(e),
'recipient': recipient,
'activity': activity,
})
return errors
def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk '''
now = http_date()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender')
digest = make_digest(data)
response = requests.post(
destination,
data=data,
headers={
'Date': now,
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': USER_AGENT,
},
)
if not response.ok:
response.raise_for_status()
return response
@receiver(models.signals.post_save)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' broadcast when a model instance is created or updated '''
# user content like statuses, lists, and shelves, have a "user" field
user = instance.user if hasattr(instance, 'user') else None
2021-02-04 16:21:55 -05:00
# we don't want to broadcast when we save remote activities
2021-02-04 15:36:39 -05:00
if user and not user.local:
return
if created:
2021-02-04 16:21:55 -05:00
# book data and users don't need to broadcast on creation
2021-02-04 15:36:39 -05:00
if not user:
return
# ordered collection items get "Add"ed
if hasattr(instance, 'to_add_activity'):
activity = instance.to_add_activity()
else:
# everything else gets "Create"d
activity = instance.to_create_activity(user)
if activity and user and user.local:
instance.broadcast(activity, user)