From 12e0e6a1f0be3112fbcbb422ff26b47470c78234 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 11:44:31 -0800 Subject: [PATCH] Broadcast implicitly from model save --- bookwyrm/broadcast.py | 87 ------------------- bookwyrm/models/activitypub_mixin.py | 121 ++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 89 deletions(-) delete mode 100644 bookwyrm/broadcast.py diff --git a/bookwyrm/broadcast.py b/bookwyrm/broadcast.py deleted file mode 100644 index f4186c4d..00000000 --- a/bookwyrm/broadcast.py +++ /dev/null @@ -1,87 +0,0 @@ -''' send out activitypub messages ''' -import json -from django.utils.http import http_date -import requests - -from bookwyrm import models, settings -from bookwyrm.activitypub import ActivityEncoder -from bookwyrm.tasks import app -from bookwyrm.signatures import make_signature, make_digest - - -def get_public_recipients(user, software=None): - ''' everybody and their public inboxes ''' - followers = user.followers.filter(local=False) - if software: - followers = followers.filter(bookwyrm_user=(software == 'bookwyrm')) - - # we want shared inboxes when available - shared = followers.filter( - shared_inbox__isnull=False - ).values_list('shared_inbox', flat=True).distinct() - - # if a user doesn't have a shared inbox, we need their personal inbox - # iirc pixelfed doesn't have shared inboxes - inboxes = followers.filter( - shared_inbox__isnull=True - ).values_list('inbox', flat=True) - - return list(shared) + list(inboxes) - - -def broadcast(sender, activity, software=None, \ - privacy='public', direct_recipients=None): - ''' send out an event ''' - # start with parsing the direct recipients - recipients = [u.inbox for u in direct_recipients or []] - # and then add any other recipients - if privacy == 'public': - recipients += get_public_recipients(sender, software=software) - broadcast_task.delay( - sender.id, - json.dumps(activity, cls=ActivityEncoder), - recipients - ) - - -@app.task -def broadcast_task(sender_id, activity, recipients): - ''' the celery task for broadcast ''' - sender = models.User.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': settings.USER_AGENT, - }, - ) - if not response.ok: - response.raise_for_status() - return response diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index adea0355..29039f7d 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -1,17 +1,25 @@ ''' base model with default fields ''' from base64 import b64encode from functools import reduce +import json import operator from uuid import uuid4 +import requests from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 from Crypto.Hash import SHA256 +from django.apps import apps from django.core.paginator import Paginator +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 PAGE_LENGTH +from bookwyrm.settings import PAGE_LENGTH, USER_AGENT +from bookwyrm.signatures import make_signature, make_digest +from bookwyrm.tasks import app from .fields import ImageField, ManyToManyField @@ -91,8 +99,54 @@ class ActivitypubMixin: return match.first() - def broadcast(self): + 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 ''' @@ -266,3 +320,66 @@ def unfurl_related_field(related_field, sort_field=None): 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 + if created: + if not hasattr(instance, 'user'): + # book data and users don't need to broadcast on creation + return + # we don't want to broadcast when we save remote activities + if not instance.user.local: + return + activity = instance.to_create_activity(instance.user) + instance.broadcast(activity, instance.user) + return + + # now, handle updates