From dfb5c396b031986b5e88364b92346d560b56a427 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 10:47:03 -0800 Subject: [PATCH 01/34] Moves activitypub mixin to its own file --- .../migrations/0017_auto_20201130_1819.py | 4 +- .../migrations/0041_auto_20210131_1614.py | 6 +- bookwyrm/models/activitypub_mixin.py | 268 ++++++++++++++++++ bookwyrm/models/attachment.py | 2 +- bookwyrm/models/base_model.py | 265 +---------------- bookwyrm/models/book.py | 3 +- bookwyrm/models/favorite.py | 3 +- bookwyrm/models/list.py | 4 +- bookwyrm/models/relationship.py | 3 +- bookwyrm/models/shelf.py | 4 +- bookwyrm/models/status.py | 2 +- bookwyrm/models/tag.py | 3 +- bookwyrm/models/user.py | 4 +- 13 files changed, 291 insertions(+), 280 deletions(-) create mode 100644 bookwyrm/models/activitypub_mixin.py diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py index ce9f1cc7..0775269b 100644 --- a/bookwyrm/migrations/0017_auto_20201130_1819.py +++ b/bookwyrm/migrations/0017_auto_20201130_1819.py @@ -1,6 +1,6 @@ # Generated by Django 3.0.7 on 2020-11-30 18:19 -import bookwyrm.models.base_model +import bookwyrm.models.activitypub_mixin import bookwyrm.models.fields from django.conf import settings from django.db import migrations, models @@ -38,7 +38,7 @@ class Migration(migrations.Migration): options={ 'abstract': False, }, - bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model), ), migrations.AddField( model_name='user', diff --git a/bookwyrm/migrations/0041_auto_20210131_1614.py b/bookwyrm/migrations/0041_auto_20210131_1614.py index 8deb69a8..6fcf406b 100644 --- a/bookwyrm/migrations/0041_auto_20210131_1614.py +++ b/bookwyrm/migrations/0041_auto_20210131_1614.py @@ -1,6 +1,6 @@ # Generated by Django 3.0.7 on 2021-01-31 16:14 -import bookwyrm.models.base_model +import bookwyrm.models.activitypub_mixin import bookwyrm.models.fields from django.conf import settings from django.db import migrations, models @@ -29,7 +29,7 @@ class Migration(migrations.Migration): options={ 'abstract': False, }, - bases=(bookwyrm.models.base_model.OrderedCollectionMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model), ), migrations.CreateModel( name='ListItem', @@ -50,7 +50,7 @@ class Migration(migrations.Migration): 'ordering': ('-created_date',), 'unique_together': {('book', 'book_list')}, }, - bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model), ), migrations.AddField( model_name='list', diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py new file mode 100644 index 00000000..adea0355 --- /dev/null +++ b/bookwyrm/models/activitypub_mixin.py @@ -0,0 +1,268 @@ +''' base model with default fields ''' +from base64 import b64encode +from functools import reduce +import operator +from uuid import uuid4 + +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from Crypto.Hash import SHA256 +from django.core.paginator import Paginator +from django.db.models import Q + +from bookwyrm import activitypub +from bookwyrm.settings import PAGE_LENGTH +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): + ''' send out an activity ''' + + def to_activity(self): + ''' convert from a model to an activity ''' + activity = generate_activity(self) + return self.activity_serializer(**activity).serialize() + + + def to_create_activity(self, user, **kwargs): + ''' returns the object wrapped in a Create activity ''' + activity_object = self.to_activity(**kwargs) + + signature = None + create_id = self.remote_id + '/activity' + if 'content' in activity_object: + signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) + content = activity_object['content'] + signed_message = signer.sign(SHA256.new(content.encode('utf8'))) + + signature = activitypub.Signature( + creator='%s#main-key' % user.remote_id, + created=activity_object['published'], + signatureValue=b64encode(signed_message).decode('utf8') + ) + + return activitypub.Create( + id=create_id, + actor=user.remote_id, + to=activity_object['to'], + cc=activity_object['cc'], + object=activity_object, + signature=signature, + ).serialize() + + + def to_delete_activity(self, user): + ''' notice of deletion ''' + return activitypub.Delete( + id=self.remote_id + '/activity', + actor=user.remote_id, + to=['%s/followers' % user.remote_id], + cc=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity(), + ).serialize() + + + def to_update_activity(self, user): + ''' wrapper for Updates to an activity ''' + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) + return activitypub.Update( + id=activity_id, + actor=user.remote_id, + to=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity() + ).serialize() + + + def to_undo_activity(self, user): + ''' undo an action ''' + return activitypub.Undo( + id='%s#undo' % self.remote_id, + actor=user.remote_id, + object=self.to_activity() + ).serialize() + + +class OrderedCollectionPageMixin(ActivitypubMixin): + ''' just the paginator utilities, so you don't HAVE to + override ActivitypubMixin's to_activity (ie, for outbox ''' + @property + def collection_remote_id(self): + ''' this can be overriden if there's a special remote id, ie outbox ''' + return self.remote_id + + + def to_ordered_collection(self, queryset, \ + remote_id=None, page=False, collection_only=False, **kwargs): + ''' an ordered collection of whatevers ''' + if not queryset.ordered: + raise RuntimeError('queryset must be ordered') + + remote_id = remote_id or self.remote_id + if page: + return to_ordered_collection_page( + queryset, remote_id, **kwargs) + + if collection_only or not hasattr(self, 'activity_serializer'): + serializer = activitypub.OrderedCollection + activity = {} + else: + serializer = self.activity_serializer + # a dict from the model fields + activity = generate_activity(self) + + if remote_id: + activity['id'] = remote_id + + paginated = Paginator(queryset, PAGE_LENGTH) + # add computed fields specific to orderd collections + activity['totalItems'] = paginated.count + activity['first'] = '%s?page=1' % remote_id + activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) + + return serializer(**activity).serialize() + + +# pylint: disable=unused-argument +def to_ordered_collection_page( + queryset, remote_id, id_only=False, page=1, **kwargs): + ''' serialize and pagiante a queryset ''' + paginated = Paginator(queryset, PAGE_LENGTH) + + activity_page = paginated.page(page) + if id_only: + items = [s.remote_id for s in activity_page.object_list] + else: + items = [s.to_activity() for s in activity_page.object_list] + + prev_page = next_page = None + if activity_page.has_next(): + next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '%s?page=%d' % \ + (remote_id, activity_page.previous_page_number()) + return activitypub.OrderedCollectionPage( + id='%s?page=%s' % (remote_id, page), + partOf=remote_id, + orderedItems=items, + next=next_page, + prev=prev_page + ).serialize() + + +class OrderedCollectionMixin(OrderedCollectionPageMixin): + ''' extends activitypub models to work as ordered collections ''' + @property + def collection_queryset(self): + ''' usually an ordered collection model aggregates a different model ''' + raise NotImplementedError('Model must define collection_queryset') + + activity_serializer = activitypub.OrderedCollection + + def to_activity(self, **kwargs): + ''' an ordered collection of the specified model queryset ''' + return self.to_ordered_collection(self.collection_queryset, **kwargs) + + +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 diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py index b3337e15..e3450a5a 100644 --- a/bookwyrm/models/attachment.py +++ b/bookwyrm/models/attachment.py @@ -2,7 +2,7 @@ from django.db import models from bookwyrm import activitypub -from .base_model import ActivitypubMixin +from .activitypub_mixin import ActivitypubMixin from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index ba0a54be..003325a7 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,20 +1,9 @@ ''' base model with default fields ''' -from base64 import b64encode -from functools import reduce -import operator -from uuid import uuid4 - -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 -from Crypto.Hash import SHA256 -from django.core.paginator import Paginator from django.db import models -from django.db.models import Q from django.dispatch import receiver -from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN, PAGE_LENGTH -from .fields import ImageField, ManyToManyField, RemoteIdField +from bookwyrm.settings import DOMAIN +from .fields import RemoteIdField class BookWyrmModel(models.Model): @@ -50,253 +39,3 @@ def execute_after_save(sender, instance, created, *args, **kwargs): if not instance.remote_id: instance.remote_id = instance.get_remote_id() instance.save() - - -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 - - -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 to_activity(self): - ''' convert from a model to an activity ''' - activity = generate_activity(self) - return self.activity_serializer(**activity).serialize() - - - def to_create_activity(self, user, **kwargs): - ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(**kwargs) - - signature = None - create_id = self.remote_id + '/activity' - if 'content' in activity_object: - signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) - content = activity_object['content'] - signed_message = signer.sign(SHA256.new(content.encode('utf8'))) - - signature = activitypub.Signature( - creator='%s#main-key' % user.remote_id, - created=activity_object['published'], - signatureValue=b64encode(signed_message).decode('utf8') - ) - - return activitypub.Create( - id=create_id, - actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], - object=activity_object, - signature=signature, - ).serialize() - - - def to_delete_activity(self, user): - ''' notice of deletion ''' - return activitypub.Delete( - id=self.remote_id + '/activity', - actor=user.remote_id, - to=['%s/followers' % user.remote_id], - cc=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity(), - ).serialize() - - - def to_update_activity(self, user): - ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (self.remote_id, uuid4()) - return activitypub.Update( - id=activity_id, - actor=user.remote_id, - to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() - ).serialize() - - - def to_undo_activity(self, user): - ''' undo an action ''' - return activitypub.Undo( - id='%s#undo' % self.remote_id, - actor=user.remote_id, - object=self.to_activity() - ).serialize() - - -class OrderedCollectionPageMixin(ActivitypubMixin): - ''' just the paginator utilities, so you don't HAVE to - override ActivitypubMixin's to_activity (ie, for outbox ''' - @property - def collection_remote_id(self): - ''' this can be overriden if there's a special remote id, ie outbox ''' - return self.remote_id - - - def to_ordered_collection(self, queryset, \ - remote_id=None, page=False, collection_only=False, **kwargs): - ''' an ordered collection of whatevers ''' - if not queryset.ordered: - raise RuntimeError('queryset must be ordered') - - remote_id = remote_id or self.remote_id - if page: - return to_ordered_collection_page( - queryset, remote_id, **kwargs) - - if collection_only or not hasattr(self, 'activity_serializer'): - serializer = activitypub.OrderedCollection - activity = {} - else: - serializer = self.activity_serializer - # a dict from the model fields - activity = generate_activity(self) - - if remote_id: - activity['id'] = remote_id - - paginated = Paginator(queryset, PAGE_LENGTH) - # add computed fields specific to orderd collections - activity['totalItems'] = paginated.count - activity['first'] = '%s?page=1' % remote_id - activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) - - return serializer(**activity).serialize() - - -# pylint: disable=unused-argument -def to_ordered_collection_page( - queryset, remote_id, id_only=False, page=1, **kwargs): - ''' serialize and pagiante a queryset ''' - paginated = Paginator(queryset, PAGE_LENGTH) - - activity_page = paginated.page(page) - if id_only: - items = [s.remote_id for s in activity_page.object_list] - else: - items = [s.to_activity() for s in activity_page.object_list] - - prev_page = next_page = None - if activity_page.has_next(): - next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) - if activity_page.has_previous(): - prev_page = '%s?page=%d' % \ - (remote_id, activity_page.previous_page_number()) - return activitypub.OrderedCollectionPage( - id='%s?page=%s' % (remote_id, page), - partOf=remote_id, - orderedItems=items, - next=next_page, - prev=prev_page - ).serialize() - - -class OrderedCollectionMixin(OrderedCollectionPageMixin): - ''' extends activitypub models to work as ordered collections ''' - @property - def collection_queryset(self): - ''' usually an ordered collection model aggregates a different model ''' - raise NotImplementedError('Model must define collection_queryset') - - activity_serializer = activitypub.OrderedCollection - - def to_activity(self, **kwargs): - ''' an ordered collection of the specified model queryset ''' - return self.to_ordered_collection(self.collection_queryset, **kwargs) - - -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 diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index ea704977..383668e0 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -7,8 +7,8 @@ from model_utils.managers import InheritanceManager from bookwyrm import activitypub from bookwyrm.settings import DOMAIN +from .activitypub_mixin import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import BookWyrmModel -from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from . import fields class BookDataModel(ActivitypubMixin, BookWyrmModel): @@ -74,6 +74,7 @@ class Book(BookDataModel): @property def latest_readthrough(self): + ''' most recent readthrough activity ''' return self.readthrough_set.order_by('-updated_date').first() @property diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index 8373b016..9809efe7 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -3,7 +3,8 @@ from django.db import models from django.utils import timezone from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import ActivitypubMixin +from .base_model import BookWyrmModel from . import fields class Favorite(ActivitypubMixin, BookWyrmModel): diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index b567929b..8a9eb519 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -3,8 +3,8 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .base_model import ActivitypubMixin, BookWyrmModel -from .base_model import OrderedCollectionMixin +from .activitypub_mixin import ActivitypubMixin, OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index ec84d44f..44af41ff 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -4,7 +4,8 @@ from django.db.models import Q from django.dispatch import receiver from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import ActivitypubMixin +from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index ff5660dd..93e9b06e 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -3,8 +3,8 @@ import re from django.db import models from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel -from .base_model import OrderedCollectionMixin +from .activitypub_mixin import ActivitypubMixin, OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 093dd773..dc170f3c 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -9,7 +9,7 @@ from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub -from .base_model import ActivitypubMixin, OrderedCollectionPageMixin +from .activitypub_mixin import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import BookWyrmModel from . import fields from .fields import image_serializer diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py index 6e0ba8ab..fce534e6 100644 --- a/bookwyrm/models/tag.py +++ b/bookwyrm/models/tag.py @@ -5,7 +5,8 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .base_model import OrderedCollectionMixin, BookWyrmModel +from .activitypub_mixin import OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 3fd0eaf7..133c0721 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -17,8 +17,8 @@ from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app from bookwyrm.utils import regex -from .base_model import OrderedCollectionPageMixin -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin +from .base_model import BookWyrmModel from .federated_server import FederatedServer from . import fields, Review From 12e0e6a1f0be3112fbcbb422ff26b47470c78234 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 11:44:31 -0800 Subject: [PATCH 02/34] 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 From feb8190d8f45c13abd48d58cc344fc5e5148c05f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 12:25:07 -0800 Subject: [PATCH 03/34] Created mixin for ordered collection items --- bookwyrm/models/activitypub_mixin.py | 71 ++++++++++++++++++++++++---- bookwyrm/models/list.py | 26 ++-------- bookwyrm/models/shelf.py | 26 ++-------- bookwyrm/models/tag.py | 25 ++-------- 4 files changed, 76 insertions(+), 72 deletions(-) diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 29039f7d..135cf89e 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -16,6 +16,7 @@ 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, USER_AGENT from bookwyrm.signatures import make_signature, make_digest @@ -55,6 +56,10 @@ class ActivitypubMixin: super().__init__(*args, **kwargs) + def delete(self, *args, **kwargs): + ''' broadcast suitable delete activities ''' + + @classmethod def find_existing_by_remote_id(cls, remote_id): ''' look up a remote id in the db ''' @@ -293,6 +298,34 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): return self.to_ordered_collection(self.collection_queryset, **kwargs) +class CollectionItemMixin(ActivitypubMixin): + ''' for items that are part of an (Ordered)Collection ''' + activity_serializer = activitypub.Add + object_field = collection_field = None + + def to_add_activity(self): + ''' AP for shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Add( + id='%s#add' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + def to_remove_activity(self): + ''' AP for un-shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Remove( + id='%s#remove' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + def generate_activity(obj): ''' go through the fields on an object ''' activity = {} @@ -371,15 +404,35 @@ def sign_and_send(sender, data, destination): 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 + user = instance.user if hasattr(instance, 'user') else None + if user and not user.local: # 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 + if created: + if not user: + # book data and users don't need to broadcast on creation + 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) + else: + # now, handle updates + if not user: + # users don't have associated users, they ARE users + if sender.__class__ == 'User': + user = instance + # book data trakcs last editor + elif hasattr(instance, 'last_edited_by'): + user = instance.last_edited_by + # again, if we don't know the user or they're remote, don't bother + if not user or not user.local: + return + activity = instance.to_update_activity(user) + + if activity and user and user.local: + instance.broadcast(activity, user) diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 8a9eb519..fab5685f 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -3,7 +3,7 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .activitypub_mixin import ActivitypubMixin, OrderedCollectionMixin +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .base_model import BookWyrmModel from . import fields @@ -49,13 +49,13 @@ class List(OrderedCollectionMixin, BookWyrmModel): ordering = ('-updated_date',) -class ListItem(ActivitypubMixin, BookWyrmModel): +class ListItem(CollectionItemMixin, BookWyrmModel): ''' ok ''' book = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, activitypub_field='object') book_list = fields.ForeignKey( 'List', on_delete=models.CASCADE, activitypub_field='target') - added_by = fields.ForeignKey( + user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor' @@ -66,24 +66,8 @@ class ListItem(ActivitypubMixin, BookWyrmModel): endorsement = models.ManyToManyField('User', related_name='endorsers') activity_serializer = activitypub.AddBook - - def to_add_activity(self, user): - ''' AP for shelving a book''' - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.book_list.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.book_list.remote_id - ).serialize() + object_field = 'book' + collection_field = 'book_list' class Meta: ''' an opinionated constraint! you can't put a book on a list twice ''' diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 93e9b06e..a1b1a48c 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -3,7 +3,7 @@ import re from django.db import models from bookwyrm import activitypub -from .activitypub_mixin import ActivitypubMixin, OrderedCollectionMixin +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .base_model import BookWyrmModel from . import fields @@ -49,13 +49,13 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): unique_together = ('user', 'identifier') -class ShelfBook(ActivitypubMixin, BookWyrmModel): +class ShelfBook(CollectionItemMixin, BookWyrmModel): ''' many to many join table for books and shelves ''' book = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, activitypub_field='object') shelf = fields.ForeignKey( 'Shelf', on_delete=models.PROTECT, activitypub_field='target') - added_by = fields.ForeignKey( + user = fields.ForeignKey( 'User', blank=True, null=True, @@ -64,24 +64,8 @@ class ShelfBook(ActivitypubMixin, BookWyrmModel): ) activity_serializer = activitypub.AddBook - - def to_add_activity(self, user): - ''' AP for shelving a book''' - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.shelf.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.shelf.to_activity() - ).serialize() + object_field = 'book' + collection_field = 'shelf' class Meta: diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py index fce534e6..d75f6e05 100644 --- a/bookwyrm/models/tag.py +++ b/bookwyrm/models/tag.py @@ -5,7 +5,7 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .activitypub_mixin import OrderedCollectionMixin +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .base_model import BookWyrmModel from . import fields @@ -41,7 +41,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel): super().save(*args, **kwargs) -class UserTag(BookWyrmModel): +class UserTag(CollectionItemMixin, BookWyrmModel): ''' an instance of a tag on a book by a user ''' user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor') @@ -51,25 +51,8 @@ class UserTag(BookWyrmModel): 'Tag', on_delete=models.PROTECT, activitypub_field='target') activity_serializer = activitypub.AddBook - - def to_add_activity(self, user): - ''' AP for shelving a book''' - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.remote_id, - ).serialize() - + object_field = 'book' + collection_field = 'tag' class Meta: ''' unqiueness constraint ''' From 44996917c78f2919d31fd4e2fd001a9613347184 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 12:36:39 -0800 Subject: [PATCH 04/34] Activitypub model code in more files --- bookwyrm/models/activitypub_mixin/__init__.py | 1 + .../activitypub_mixin/activitypub_mixin.py | 330 ++++++++++++++++++ .../activitypub_mixin/ordered_collection.py | 115 ++++++ 3 files changed, 446 insertions(+) create mode 100644 bookwyrm/models/activitypub_mixin/__init__.py create mode 100644 bookwyrm/models/activitypub_mixin/activitypub_mixin.py create mode 100644 bookwyrm/models/activitypub_mixin/ordered_collection.py diff --git a/bookwyrm/models/activitypub_mixin/__init__.py b/bookwyrm/models/activitypub_mixin/__init__.py new file mode 100644 index 00000000..b6e690fd --- /dev/null +++ b/bookwyrm/models/activitypub_mixin/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/models/activitypub_mixin/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin/activitypub_mixin.py new file mode 100644 index 00000000..61f042ee --- /dev/null +++ b/bookwyrm/models/activitypub_mixin/activitypub_mixin.py @@ -0,0 +1,330 @@ +''' 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.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) + + + def delete(self, *args, **kwargs): + ''' broadcast suitable delete activities ''' + + + @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 to_create_activity(self, user, **kwargs): + ''' returns the object wrapped in a Create activity ''' + activity_object = self.to_activity(**kwargs) + + signature = None + create_id = self.remote_id + '/activity' + if 'content' in activity_object: + signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) + content = activity_object['content'] + signed_message = signer.sign(SHA256.new(content.encode('utf8'))) + + signature = activitypub.Signature( + creator='%s#main-key' % user.remote_id, + created=activity_object['published'], + signatureValue=b64encode(signed_message).decode('utf8') + ) + + return activitypub.Create( + id=create_id, + actor=user.remote_id, + to=activity_object['to'], + cc=activity_object['cc'], + object=activity_object, + signature=signature, + ).serialize() + + + def to_delete_activity(self, user): + ''' notice of deletion ''' + return activitypub.Delete( + id=self.remote_id + '/activity', + actor=user.remote_id, + to=['%s/followers' % user.remote_id], + cc=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity(), + ).serialize() + + + def to_update_activity(self, user): + ''' wrapper for Updates to an activity ''' + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) + return activitypub.Update( + id=activity_id, + actor=user.remote_id, + to=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity() + ).serialize() + + + def to_undo_activity(self, user): + ''' undo an action ''' + return activitypub.Undo( + id='%s#undo' % self.remote_id, + actor=user.remote_id, + object=self.to_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 + if user and not user.local: + # we don't want to broadcast when we save remote activities + return + + if created: + if not user: + # book data and users don't need to broadcast on creation + 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) + else: + # now, handle updates + if not user: + # users don't have associated users, they ARE users + if sender.__class__ == 'User': + user = instance + # book data trakcs last editor + elif hasattr(instance, 'last_edited_by'): + user = instance.last_edited_by + # again, if we don't know the user or they're remote, don't bother + if not user or not user.local: + return + activity = instance.to_update_activity(user) + + if activity and user and user.local: + instance.broadcast(activity, user) diff --git a/bookwyrm/models/activitypub_mixin/ordered_collection.py b/bookwyrm/models/activitypub_mixin/ordered_collection.py new file mode 100644 index 00000000..e755e522 --- /dev/null +++ b/bookwyrm/models/activitypub_mixin/ordered_collection.py @@ -0,0 +1,115 @@ +''' lists of objects ''' +from django.core.paginator import Paginator + +from bookwyrm import activitypub +from bookwyrm.settings import PAGE_LENGTH +from . import ActivitypubMixin, generate_activity + + +class OrderedCollectionPageMixin(ActivitypubMixin): + ''' just the paginator utilities, so you don't HAVE to + override ActivitypubMixin's to_activity (ie, for outbox ''' + @property + def collection_remote_id(self): + ''' this can be overriden if there's a special remote id, ie outbox ''' + return self.remote_id + + + def to_ordered_collection(self, queryset, \ + remote_id=None, page=False, collection_only=False, **kwargs): + ''' an ordered collection of whatevers ''' + if not queryset.ordered: + raise RuntimeError('queryset must be ordered') + + remote_id = remote_id or self.remote_id + if page: + return to_ordered_collection_page( + queryset, remote_id, **kwargs) + + if collection_only or not hasattr(self, 'activity_serializer'): + serializer = activitypub.OrderedCollection + activity = {} + else: + serializer = self.activity_serializer + # a dict from the model fields + activity = generate_activity(self) + + if remote_id: + activity['id'] = remote_id + + paginated = Paginator(queryset, PAGE_LENGTH) + # add computed fields specific to orderd collections + activity['totalItems'] = paginated.count + activity['first'] = '%s?page=1' % remote_id + activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) + + return serializer(**activity).serialize() + + +# pylint: disable=unused-argument +def to_ordered_collection_page( + queryset, remote_id, id_only=False, page=1, **kwargs): + ''' serialize and pagiante a queryset ''' + paginated = Paginator(queryset, PAGE_LENGTH) + + activity_page = paginated.page(page) + if id_only: + items = [s.remote_id for s in activity_page.object_list] + else: + items = [s.to_activity() for s in activity_page.object_list] + + prev_page = next_page = None + if activity_page.has_next(): + next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '%s?page=%d' % \ + (remote_id, activity_page.previous_page_number()) + return activitypub.OrderedCollectionPage( + id='%s?page=%s' % (remote_id, page), + partOf=remote_id, + orderedItems=items, + next=next_page, + prev=prev_page + ).serialize() + + +class OrderedCollectionMixin(OrderedCollectionPageMixin): + ''' extends activitypub models to work as ordered collections ''' + @property + def collection_queryset(self): + ''' usually an ordered collection model aggregates a different model ''' + raise NotImplementedError('Model must define collection_queryset') + + activity_serializer = activitypub.OrderedCollection + + def to_activity(self, **kwargs): + ''' an ordered collection of the specified model queryset ''' + return self.to_ordered_collection(self.collection_queryset, **kwargs) + + +class CollectionItemMixin(ActivitypubMixin): + ''' for items that are part of an (Ordered)Collection ''' + activity_serializer = activitypub.Add + object_field = collection_field = None + + def to_add_activity(self): + ''' AP for shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Add( + id='%s#add' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + def to_remove_activity(self): + ''' AP for un-shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Remove( + id='%s#remove' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() From 42d80ce2385e3ef436900dbd6163445ca24cf76c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 13:21:55 -0800 Subject: [PATCH 05/34] Remove explicit broadcast calls --- bookwyrm/goodreads_import.py | 9 +- bookwyrm/models/activitypub_mixin.py | 438 ------------------ .../activitypub_mixin/activitypub_mixin.py | 89 +--- .../activitypub_mixin/ordered_collection.py | 6 +- bookwyrm/models/book.py | 4 +- bookwyrm/views/author.py | 2 - bookwyrm/views/block.py | 18 +- bookwyrm/views/books.py | 9 +- bookwyrm/views/follow.py | 20 +- bookwyrm/views/goal.py | 16 +- bookwyrm/views/helpers.py | 2 - bookwyrm/views/interaction.py | 16 +- bookwyrm/views/list.py | 39 +- bookwyrm/views/reading.py | 7 +- bookwyrm/views/shelf.py | 13 +- bookwyrm/views/status.py | 11 - bookwyrm/views/tag.py | 9 +- bookwyrm/views/user.py | 2 - 18 files changed, 25 insertions(+), 685 deletions(-) delete mode 100644 bookwyrm/models/activitypub_mixin.py diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index b19994ed..606705c8 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -3,7 +3,6 @@ import csv import logging from bookwyrm import models -from bookwyrm.broadcast import broadcast from bookwyrm.models import ImportJob, ImportItem from bookwyrm.status import create_notification from bookwyrm.tasks import app @@ -90,9 +89,8 @@ def handle_imported_book(user, item, include_reviews, privacy): identifier=item.shelf, user=user ) - shelf_book = models.ShelfBook.objects.create( + models.ShelfBook.objects.create( book=item.book, shelf=desired_shelf, added_by=user) - broadcast(user, shelf_book.to_add_activity(user), privacy=privacy) for read in item.reads: # check for an existing readthrough with the same dates @@ -114,7 +112,7 @@ def handle_imported_book(user, item, include_reviews, privacy): # we don't know the publication date of the review, # but "now" is a bad guess published_date_guess = item.date_read or item.date_added - review = models.Review.objects.create( + models.Review.objects.create( user=user, book=item.book, name=review_title, @@ -123,6 +121,3 @@ def handle_imported_book(user, item, include_reviews, privacy): published_date=published_date_guess, privacy=privacy, ) - # we don't need to send out pure activities because non-bookwyrm - # instances don't need this data - broadcast(user, review.to_create_activity(user), privacy=privacy) diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py deleted file mode 100644 index 135cf89e..00000000 --- a/bookwyrm/models/activitypub_mixin.py +++ /dev/null @@ -1,438 +0,0 @@ -''' 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, 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) - - - def delete(self, *args, **kwargs): - ''' broadcast suitable delete activities ''' - - - @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 to_create_activity(self, user, **kwargs): - ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(**kwargs) - - signature = None - create_id = self.remote_id + '/activity' - if 'content' in activity_object: - signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) - content = activity_object['content'] - signed_message = signer.sign(SHA256.new(content.encode('utf8'))) - - signature = activitypub.Signature( - creator='%s#main-key' % user.remote_id, - created=activity_object['published'], - signatureValue=b64encode(signed_message).decode('utf8') - ) - - return activitypub.Create( - id=create_id, - actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], - object=activity_object, - signature=signature, - ).serialize() - - - def to_delete_activity(self, user): - ''' notice of deletion ''' - return activitypub.Delete( - id=self.remote_id + '/activity', - actor=user.remote_id, - to=['%s/followers' % user.remote_id], - cc=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity(), - ).serialize() - - - def to_update_activity(self, user): - ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (self.remote_id, uuid4()) - return activitypub.Update( - id=activity_id, - actor=user.remote_id, - to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() - ).serialize() - - - def to_undo_activity(self, user): - ''' undo an action ''' - return activitypub.Undo( - id='%s#undo' % self.remote_id, - actor=user.remote_id, - object=self.to_activity() - ).serialize() - - -class OrderedCollectionPageMixin(ActivitypubMixin): - ''' just the paginator utilities, so you don't HAVE to - override ActivitypubMixin's to_activity (ie, for outbox ''' - @property - def collection_remote_id(self): - ''' this can be overriden if there's a special remote id, ie outbox ''' - return self.remote_id - - - def to_ordered_collection(self, queryset, \ - remote_id=None, page=False, collection_only=False, **kwargs): - ''' an ordered collection of whatevers ''' - if not queryset.ordered: - raise RuntimeError('queryset must be ordered') - - remote_id = remote_id or self.remote_id - if page: - return to_ordered_collection_page( - queryset, remote_id, **kwargs) - - if collection_only or not hasattr(self, 'activity_serializer'): - serializer = activitypub.OrderedCollection - activity = {} - else: - serializer = self.activity_serializer - # a dict from the model fields - activity = generate_activity(self) - - if remote_id: - activity['id'] = remote_id - - paginated = Paginator(queryset, PAGE_LENGTH) - # add computed fields specific to orderd collections - activity['totalItems'] = paginated.count - activity['first'] = '%s?page=1' % remote_id - activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) - - return serializer(**activity).serialize() - - -# pylint: disable=unused-argument -def to_ordered_collection_page( - queryset, remote_id, id_only=False, page=1, **kwargs): - ''' serialize and pagiante a queryset ''' - paginated = Paginator(queryset, PAGE_LENGTH) - - activity_page = paginated.page(page) - if id_only: - items = [s.remote_id for s in activity_page.object_list] - else: - items = [s.to_activity() for s in activity_page.object_list] - - prev_page = next_page = None - if activity_page.has_next(): - next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) - if activity_page.has_previous(): - prev_page = '%s?page=%d' % \ - (remote_id, activity_page.previous_page_number()) - return activitypub.OrderedCollectionPage( - id='%s?page=%s' % (remote_id, page), - partOf=remote_id, - orderedItems=items, - next=next_page, - prev=prev_page - ).serialize() - - -class OrderedCollectionMixin(OrderedCollectionPageMixin): - ''' extends activitypub models to work as ordered collections ''' - @property - def collection_queryset(self): - ''' usually an ordered collection model aggregates a different model ''' - raise NotImplementedError('Model must define collection_queryset') - - activity_serializer = activitypub.OrderedCollection - - def to_activity(self, **kwargs): - ''' an ordered collection of the specified model queryset ''' - return self.to_ordered_collection(self.collection_queryset, **kwargs) - - -class CollectionItemMixin(ActivitypubMixin): - ''' for items that are part of an (Ordered)Collection ''' - activity_serializer = activitypub.Add - object_field = collection_field = None - - def to_add_activity(self): - ''' AP for shelving a book''' - object_field = getattr(self, self.object_field) - collection_field = getattr(self, self.collection_field) - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=self.user.remote_id, - object=object_field.to_activity(), - target=collection_field.remote_id - ).serialize() - - def to_remove_activity(self): - ''' AP for un-shelving a book''' - object_field = getattr(self, self.object_field) - collection_field = getattr(self, self.collection_field) - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=self.user.remote_id, - object=object_field.to_activity(), - target=collection_field.remote_id - ).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 - if user and not user.local: - # we don't want to broadcast when we save remote activities - return - - if created: - if not user: - # book data and users don't need to broadcast on creation - 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) - else: - # now, handle updates - if not user: - # users don't have associated users, they ARE users - if sender.__class__ == 'User': - user = instance - # book data trakcs last editor - elif hasattr(instance, 'last_edited_by'): - user = instance.last_edited_by - # again, if we don't know the user or they're remote, don't bother - if not user or not user.local: - return - activity = instance.to_update_activity(user) - - if activity and user and user.local: - instance.broadcast(activity, user) diff --git a/bookwyrm/models/activitypub_mixin/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin/activitypub_mixin.py index 61f042ee..eef5ce2b 100644 --- a/bookwyrm/models/activitypub_mixin/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin/activitypub_mixin.py @@ -1,14 +1,9 @@ -''' base model with default fields ''' -from base64 import b64encode +''' activitypub model functionality ''' 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.db import models from django.db.models import Q @@ -55,10 +50,6 @@ class ActivitypubMixin: super().__init__(*args, **kwargs) - def delete(self, *args, **kwargs): - ''' broadcast suitable delete activities ''' - - @classmethod def find_existing_by_remote_id(cls, remote_id): ''' look up a remote id in the db ''' @@ -158,66 +149,6 @@ class ActivitypubMixin: return self.activity_serializer(**activity).serialize() - def to_create_activity(self, user, **kwargs): - ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(**kwargs) - - signature = None - create_id = self.remote_id + '/activity' - if 'content' in activity_object: - signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) - content = activity_object['content'] - signed_message = signer.sign(SHA256.new(content.encode('utf8'))) - - signature = activitypub.Signature( - creator='%s#main-key' % user.remote_id, - created=activity_object['published'], - signatureValue=b64encode(signed_message).decode('utf8') - ) - - return activitypub.Create( - id=create_id, - actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], - object=activity_object, - signature=signature, - ).serialize() - - - def to_delete_activity(self, user): - ''' notice of deletion ''' - return activitypub.Delete( - id=self.remote_id + '/activity', - actor=user.remote_id, - to=['%s/followers' % user.remote_id], - cc=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity(), - ).serialize() - - - def to_update_activity(self, user): - ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (self.remote_id, uuid4()) - return activitypub.Update( - id=activity_id, - actor=user.remote_id, - to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() - ).serialize() - - - def to_undo_activity(self, user): - ''' undo an action ''' - return activitypub.Undo( - id='%s#undo' % self.remote_id, - actor=user.remote_id, - object=self.to_activity() - ).serialize() - - - - def generate_activity(obj): ''' go through the fields on an object ''' activity = {} @@ -297,13 +228,14 @@ 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 + + # we don't want to broadcast when we save remote activities if user and not user.local: - # we don't want to broadcast when we save remote activities return if created: + # book data and users don't need to broadcast on creation if not user: - # book data and users don't need to broadcast on creation return # ordered collection items get "Add"ed @@ -312,19 +244,6 @@ def execute_after_save(sender, instance, created, *args, **kwargs): else: # everything else gets "Create"d activity = instance.to_create_activity(user) - else: - # now, handle updates - if not user: - # users don't have associated users, they ARE users - if sender.__class__ == 'User': - user = instance - # book data trakcs last editor - elif hasattr(instance, 'last_edited_by'): - user = instance.last_edited_by - # again, if we don't know the user or they're remote, don't bother - if not user or not user.local: - return - activity = instance.to_update_activity(user) if activity and user and user.local: instance.broadcast(activity, user) diff --git a/bookwyrm/models/activitypub_mixin/ordered_collection.py b/bookwyrm/models/activitypub_mixin/ordered_collection.py index e755e522..954e0364 100644 --- a/bookwyrm/models/activitypub_mixin/ordered_collection.py +++ b/bookwyrm/models/activitypub_mixin/ordered_collection.py @@ -3,12 +3,12 @@ from django.core.paginator import Paginator from bookwyrm import activitypub from bookwyrm.settings import PAGE_LENGTH -from . import ActivitypubMixin, generate_activity +from . import ActivitypubMixin, ObjectMixin, generate_activity -class OrderedCollectionPageMixin(ActivitypubMixin): +class OrderedCollectionPageMixin(ObjectMixin): ''' just the paginator utilities, so you don't HAVE to - override ActivitypubMixin's to_activity (ie, for outbox ''' + override ActivitypubMixin's to_activity (ie, for outbox) ''' @property def collection_remote_id(self): ''' this can be overriden if there's a special remote id, ie outbox ''' diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 383668e0..ef1dd96d 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -7,11 +7,11 @@ from model_utils.managers import InheritanceManager from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .activitypub_mixin import ActivitypubMixin, OrderedCollectionPageMixin +from .activitypub_mixin import ObjectMixin, OrderedCollectionPageMixin from .base_model import BookWyrmModel from . import fields -class BookDataModel(ActivitypubMixin, BookWyrmModel): +class BookDataModel(ObjectMixin, BookWyrmModel): ''' fields shared between editable book data (books, works, authors) ''' origin_id = models.CharField(max_length=255, null=True, blank=True) openlibrary_key = fields.CharField( diff --git a/bookwyrm/views/author.py b/bookwyrm/views/author.py index ad9d1873..a1a37cc5 100644 --- a/bookwyrm/views/author.py +++ b/bookwyrm/views/author.py @@ -8,7 +8,6 @@ from django.views import View from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from .helpers import is_api_request @@ -62,5 +61,4 @@ class EditAuthor(View): return TemplateResponse(request, 'edit_author.html', data) author = form.save() - broadcast(request.user, author.to_update_activity(request.user)) return redirect('/author/%s' % author.id) diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py index ebcced2a..cb14aae3 100644 --- a/bookwyrm/views/block.py +++ b/bookwyrm/views/block.py @@ -8,7 +8,6 @@ from django.views import View from django.views.decorators.http import require_POST from bookwyrm import models -from bookwyrm.broadcast import broadcast # pylint: disable= no-self-use @method_decorator(login_required, name='dispatch') @@ -22,15 +21,8 @@ class Block(View): def post(self, request, user_id): ''' block a user ''' to_block = get_object_or_404(models.User, id=user_id) - block = models.UserBlocks.objects.create( + models.UserBlocks.objects.create( user_subject=request.user, user_object=to_block) - if not to_block.local: - broadcast( - request.user, - block.to_activity(), - privacy='direct', - direct_recipients=[to_block] - ) return redirect('/preferences/block') @@ -46,13 +38,5 @@ def unblock(request, user_id): ) except models.UserBlocks.DoesNotExist: return HttpResponseNotFound() - - if not to_unblock.local: - broadcast( - request.user, - block.to_undo_activity(request.user), - privacy='direct', - direct_recipients=[to_unblock] - ) block.delete() return redirect('/preferences/block') diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 52704560..02c7f7b4 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -12,7 +12,6 @@ from django.views.decorators.http import require_POST from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from bookwyrm.connectors import connector_manager from bookwyrm.settings import PAGE_LENGTH from .helpers import is_api_request, get_activity_feed, get_edition @@ -136,7 +135,6 @@ class EditBook(View): return TemplateResponse(request, 'edit_book.html', data) book = form.save() - broadcast(request.user, book.to_update_activity(request.user)) return redirect('/book/%s' % book.id) @@ -170,7 +168,6 @@ def upload_cover(request, book_id): book.cover = form.files['cover'] book.save() - broadcast(request.user, book.to_update_activity(request.user)) return redirect('/book/%s' % book.id) @@ -189,7 +186,6 @@ def add_description(request, book_id): book.description = description book.save() - broadcast(request.user, book.to_update_activity(request.user)) return redirect('/book/%s' % book.id) @@ -215,13 +211,10 @@ def switch_edition(request): shelf__user=request.user ) for shelfbook in shelfbooks.all(): - broadcast(request.user, shelfbook.to_remove_activity(request.user)) - + # TODO: this needs to be a delete and re-create shelfbook.book = new_edition shelfbook.save() - broadcast(request.user, shelfbook.to_add_activity(request.user)) - readthroughs = models.ReadThrough.objects.filter( book__parent_work=new_edition.parent_work, user=request.user diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py index 08b6cca3..992b0dc1 100644 --- a/bookwyrm/views/follow.py +++ b/bookwyrm/views/follow.py @@ -6,7 +6,6 @@ from django.shortcuts import redirect from django.views.decorators.http import require_POST from bookwyrm import models -from bookwyrm.broadcast import broadcast from .helpers import get_user_from_username @login_required @@ -19,13 +18,10 @@ def follow(request): except models.User.DoesNotExist: return HttpResponseBadRequest() - relationship, _ = models.UserFollowRequest.objects.get_or_create( + models.UserFollowRequest.objects.get_or_create( user_subject=request.user, user_object=to_follow, ) - activity = relationship.to_activity() - broadcast( - request.user, activity, privacy='direct', direct_recipients=[to_follow]) return redirect(to_follow.local_path) @@ -39,14 +35,10 @@ def unfollow(request): except models.User.DoesNotExist: return HttpResponseBadRequest() - relationship = models.UserFollows.objects.get( + models.UserFollows.objects.get( user_subject=request.user, user_object=to_unfollow ) - activity = relationship.to_undo_activity(request.user) - broadcast( - request.user, activity, - privacy='direct', direct_recipients=[to_unfollow]) to_unfollow.followers.remove(request.user) return redirect(to_unfollow.local_path) @@ -77,16 +69,11 @@ def accept_follow_request(request): def handle_accept(follow_request): ''' send an acceptance message to a follow request ''' - user = follow_request.user_subject - to_follow = follow_request.user_object with transaction.atomic(): relationship = models.UserFollows.from_request(follow_request) follow_request.delete() relationship.save() - activity = relationship.to_accept_activity() - broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) - @login_required @require_POST @@ -106,8 +93,5 @@ def delete_follow_request(request): except models.UserFollowRequest.DoesNotExist: return HttpResponseBadRequest() - activity = follow_request.to_reject_activity() follow_request.delete() - broadcast( - request.user, activity, privacy='direct', direct_recipients=[requester]) return redirect('/user/%s' % request.user.localname) diff --git a/bookwyrm/views/goal.py b/bookwyrm/views/goal.py index 0496848f..87404b10 100644 --- a/bookwyrm/views/goal.py +++ b/bookwyrm/views/goal.py @@ -7,7 +7,6 @@ from django.utils.decorators import method_decorator from django.views import View from bookwyrm import forms, models -from bookwyrm.broadcast import broadcast from bookwyrm.status import create_generated_note from .helpers import get_user_from_username, object_visible_to_user @@ -63,23 +62,10 @@ class Goal(View): if request.POST.get('post-status'): # create status, if appropraite - status = create_generated_note( + create_generated_note( request.user, 'set a goal to read %d books in %d' % (goal.goal, goal.year), privacy=goal.privacy ) - broadcast( - request.user, - status.to_create_activity(request.user), - privacy=status.privacy, - software='bookwyrm') - - # re-format the activity for non-bookwyrm servers - remote_activity = status.to_create_activity(request.user, pure=True) - broadcast( - request.user, - remote_activity, - privacy=status.privacy, - software='other') return redirect(request.headers.get('Referer', '/')) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 8742884f..842b8d1c 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -4,7 +4,6 @@ from requests import HTTPError from django.db.models import Q from bookwyrm import activitypub, models -from bookwyrm.broadcast import broadcast from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.status import create_generated_note from bookwyrm.utils import regex @@ -199,7 +198,6 @@ def handle_reading_status(user, shelf, book, privacy): ) status.save() - broadcast(user, status.to_create_activity(user)) def is_blocked(viewer, user): ''' is this viewer blocked by the user? ''' diff --git a/bookwyrm/views/interaction.py b/bookwyrm/views/interaction.py index a6732c52..ebee4719 100644 --- a/bookwyrm/views/interaction.py +++ b/bookwyrm/views/interaction.py @@ -7,7 +7,6 @@ from django.utils.decorators import method_decorator from django.views import View from bookwyrm import models -from bookwyrm.broadcast import broadcast from bookwyrm.status import create_notification @@ -19,7 +18,7 @@ class Favorite(View): ''' create a like ''' status = models.Status.objects.get(id=status_id) try: - favorite = models.Favorite.objects.create( + models.Favorite.objects.create( status=status, user=request.user ) @@ -27,10 +26,6 @@ class Favorite(View): # you already fav'ed that return HttpResponseBadRequest() - fav_activity = favorite.to_activity() - broadcast( - request.user, fav_activity, privacy='direct', - direct_recipients=[status.user]) if status.user.local: create_notification( status.user, @@ -56,9 +51,7 @@ class Unfavorite(View): # can't find that status, idk return HttpResponseNotFound() - fav_activity = favorite.to_undo_activity(request.user) favorite.delete() - broadcast(request.user, fav_activity, direct_recipients=[status.user]) # check for notification if status.user.local: @@ -86,15 +79,12 @@ class Boost(View): # you already boosted that. return redirect(request.headers.get('Referer', '/')) - boost = models.Boost.objects.create( + models.Boost.objects.create( boosted_status=status, privacy=status.privacy, user=request.user, ) - boost_activity = boost.to_activity() - broadcast(request.user, boost_activity) - if status.user.local: create_notification( status.user, @@ -114,10 +104,8 @@ class Unboost(View): boost = models.Boost.objects.filter( boosted_status=status, user=request.user ).first() - activity = boost.to_undo_activity(request.user) boost.delete() - broadcast(request.user, activity) # delete related notification if status.user.local: diff --git a/bookwyrm/views/list.py b/bookwyrm/views/list.py index 5128c2b0..17c0e2f3 100644 --- a/bookwyrm/views/list.py +++ b/bookwyrm/views/list.py @@ -11,7 +11,6 @@ from django.views.decorators.http import require_POST from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from bookwyrm.connectors import connector_manager from .helpers import is_api_request, object_visible_to_user, privacy_filter from .helpers import get_user_from_username @@ -51,13 +50,6 @@ class Lists(View): return redirect('lists') book_list = form.save() - # let the world know - broadcast( - request.user, - book_list.to_create_activity(request.user), - privacy=book_list.privacy, - software='bookwyrm' - ) return redirect(book_list.local_path) class UserLists(View): @@ -138,13 +130,6 @@ class List(View): if not form.is_valid(): return redirect('list', book_list.id) book_list = form.save() - # let the world know - broadcast( - request.user, - book_list.to_update_activity(request.user), - privacy=book_list.privacy, - software='bookwyrm' - ) return redirect(book_list.local_path) @@ -178,13 +163,6 @@ class Curate(View): if approved: suggestion.approved = True suggestion.save() - # let the world know - broadcast( - request.user, - suggestion.to_add_activity(request.user), - privacy=book_list.privacy, - software='bookwyrm' - ) else: suggestion.delete() return redirect('list-curate', book_list.id) @@ -201,18 +179,11 @@ def add_book(request, list_id): # do you have permission to add to the list? if request.user == book_list.user or book_list.curation == 'open': # go ahead and add it - item = models.ListItem.objects.create( + models.ListItem.objects.create( book=book, book_list=book_list, added_by=request.user, ) - # let the world know - broadcast( - request.user, - item.to_add_activity(request.user), - privacy=book_list.privacy, - software='bookwyrm' - ) elif book_list.curation == 'curated': # make a pending entry models.ListItem.objects.create( @@ -237,13 +208,5 @@ def remove_book(request, list_id): if not book_list.user == request.user and not item.added_by == request.user: return HttpResponseNotFound() - activity = item.to_remove_activity(request.user) item.delete() - # let the world know - broadcast( - request.user, - activity, - privacy=book_list.privacy, - software='bookwyrm' - ) return redirect('list', list_id) diff --git a/bookwyrm/views/reading.py b/bookwyrm/views/reading.py index c2c72509..ba6c8a4a 100644 --- a/bookwyrm/views/reading.py +++ b/bookwyrm/views/reading.py @@ -9,7 +9,6 @@ from django.utils import timezone from django.views.decorators.http import require_POST from bookwyrm import models -from bookwyrm.broadcast import broadcast from .helpers import get_edition, handle_reading_status from .shelf import handle_unshelve @@ -44,9 +43,8 @@ def start_reading(request, book_id): except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass - shelfbook = models.ShelfBook.objects.create( + models.ShelfBook.objects.create( book=book, shelf=shelf, added_by=request.user) - broadcast(request.user, shelfbook.to_add_activity(request.user)) # post about it (if you want) if request.POST.get('post-status'): @@ -82,9 +80,8 @@ def finish_reading(request, book_id): except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass - shelfbook = models.ShelfBook.objects.create( + models.ShelfBook.objects.create( book=book, shelf=shelf, added_by=request.user) - broadcast(request.user, shelfbook.to_add_activity(request.user)) # post about it (if you want) if request.POST.get('post-status'): diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py index 508d5ea5..dca53ace 100644 --- a/bookwyrm/views/shelf.py +++ b/bookwyrm/views/shelf.py @@ -9,7 +9,6 @@ from django.views.decorators.http import require_POST from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from .helpers import is_api_request, get_edition, get_user_from_username from .helpers import handle_reading_status @@ -136,14 +135,8 @@ def shelve(request): except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass - shelfbook = models.ShelfBook.objects.create( + models.ShelfBook.objects.create( book=book, shelf=desired_shelf, added_by=request.user) - broadcast( - request.user, - shelfbook.to_add_activity(request.user), - privacy=shelfbook.shelf.privacy, - software='bookwyrm' - ) # post about "want to read" shelves if desired_shelf.identifier == 'to-read': @@ -168,10 +161,8 @@ def unshelve(request): return redirect(request.headers.get('Referer', '/')) +#pylint: disable=unused-argument def handle_unshelve(user, book, shelf): ''' unshelve a book ''' row = models.ShelfBook.objects.get(book=book, shelf=shelf) - activity = row.to_remove_activity(user) row.delete() - - broadcast(user, activity, privacy=shelf.privacy, software='bookwyrm') diff --git a/bookwyrm/views/status.py b/bookwyrm/views/status.py index 4d342bfb..4b2b4519 100644 --- a/bookwyrm/views/status.py +++ b/bookwyrm/views/status.py @@ -8,7 +8,6 @@ from django.views import View from markdown import markdown from bookwyrm import forms, models -from bookwyrm.broadcast import broadcast from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.settings import DOMAIN from bookwyrm.status import create_notification, delete_status @@ -84,15 +83,6 @@ class CreateStatus(View): status.quote = to_markdown(status.quote) status.save() - - broadcast( - request.user, - status.to_create_activity(request.user), - software='bookwyrm') - - # re-format the activity for non-bookwyrm servers - remote_activity = status.to_create_activity(request.user, pure=True) - broadcast(request.user, remote_activity, software='other') return redirect(request.headers.get('Referer', '/')) @@ -108,7 +98,6 @@ class DeleteStatus(View): # perform deletion delete_status(status) - broadcast(request.user, status.to_delete_activity(request.user)) return redirect(request.headers.get('Referer', '/')) def find_mentions(content): diff --git a/bookwyrm/views/tag.py b/bookwyrm/views/tag.py index e95ffe81..b50bc0ef 100644 --- a/bookwyrm/views/tag.py +++ b/bookwyrm/views/tag.py @@ -8,7 +8,6 @@ from django.views import View from bookwyrm import models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from .helpers import is_api_request @@ -45,17 +44,15 @@ class AddTag(View): name = request.POST.get('name') book_id = request.POST.get('book') book = get_object_or_404(models.Edition, id=book_id) - tag_obj, created = models.Tag.objects.get_or_create( + tag_obj, _ = models.Tag.objects.get_or_create( name=name, ) - user_tag, _ = models.UserTag.objects.get_or_create( + models.UserTag.objects.get_or_create( user=request.user, book=book, tag=tag_obj, ) - if created: - broadcast(request.user, user_tag.to_add_activity(request.user)) return redirect('/book/%s' % book_id) @@ -71,8 +68,6 @@ class RemoveTag(View): user_tag = get_object_or_404( models.UserTag, tag=tag_obj, book=book, user=request.user) - tag_activity = user_tag.to_remove_activity(request.user) user_tag.delete() - broadcast(request.user, tag_activity) return redirect('/book/%s' % book_id) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index a06face2..4da0fdac 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -15,7 +15,6 @@ from django.views import View from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.broadcast import broadcast from bookwyrm.settings import PAGE_LENGTH from .helpers import get_activity_feed, get_user_from_username, is_api_request from .helpers import is_blocked, object_visible_to_user @@ -176,7 +175,6 @@ class EditUser(View): user.avatar.save(filename, image) user.save() - broadcast(user, user.to_update_activity(user)) return redirect(user.local_path) From 5a3a6151a6304f688ab98dfe20d44e51cacdbc16 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 13:22:27 -0800 Subject: [PATCH 06/34] separate mixins for AP types --- .../activitypub_mixin/activity_mixin.py | 25 +++++ .../models/activitypub_mixin/object_mixin.py | 94 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 bookwyrm/models/activitypub_mixin/activity_mixin.py create mode 100644 bookwyrm/models/activitypub_mixin/object_mixin.py diff --git a/bookwyrm/models/activitypub_mixin/activity_mixin.py b/bookwyrm/models/activitypub_mixin/activity_mixin.py new file mode 100644 index 00000000..fc74fe17 --- /dev/null +++ b/bookwyrm/models/activitypub_mixin/activity_mixin.py @@ -0,0 +1,25 @@ +''' activitypub model functionality ''' +from bookwyrm import activitypub +from . import ActivitypubMixin + +class ActivitybMixin(ActivitypubMixin): + ''' add this mixin for models that are AP serializable ''' + + def save(self, *args, **kwargs): + ''' broadcast activity ''' + super().save(*args, **kwargs) + self.broadcast(self.to_activity(), self.user) + + def delete(self, *args, **kwargs): + ''' nevermind, undo that activity ''' + self.broadcast(self.to_undo_activity(), self.user) + super().delete(*args, **kwargs) + + + def to_undo_activity(self): + ''' undo an action ''' + return activitypub.Undo( + id='%s#undo' % self.remote_id, + actor=self.user.remote_id, + object=self.to_activity() + ).serialize() diff --git a/bookwyrm/models/activitypub_mixin/object_mixin.py b/bookwyrm/models/activitypub_mixin/object_mixin.py new file mode 100644 index 00000000..2eddf813 --- /dev/null +++ b/bookwyrm/models/activitypub_mixin/object_mixin.py @@ -0,0 +1,94 @@ +''' activitypub objects like Person and Book''' +from base64 import b64encode +from uuid import uuid4 + +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from Crypto.Hash import SHA256 +from django.apps import apps + +from bookwyrm import activitypub +from . import ActivitypubMixin + + +class ObjectMixin(ActivitypubMixin): + ''' add this mixin for object models that are AP serializable ''' + + def save(self, *args, **kwargs): + ''' broadcast updated ''' + # first off, we want to save normally no matter what + super().save(*args, **kwargs) + + # we only want to handle updates, not newly created objects + if not self.id: + return + + # this will work for lists, shelves + user = self.user if hasattr(self, 'user') else None + if not user: + # users don't have associated users, they ARE users + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if isinstance(self, user_model): + user = self + # book data tracks last editor + elif hasattr(self, 'last_edited_by'): + user = self.last_edited_by + # again, if we don't know the user or they're remote, don't bother + if not user or not user.local: + return + + # is this a deletion? + if self.deleted: + activity = self.to_delete_activity(user) + else: + activity = self.to_update_activity(user) + self.broadcast(activity, user) + + + def to_create_activity(self, user, **kwargs): + ''' returns the object wrapped in a Create activity ''' + activity_object = self.to_activity(**kwargs) + + signature = None + create_id = self.remote_id + '/activity' + if 'content' in activity_object: + signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) + content = activity_object['content'] + signed_message = signer.sign(SHA256.new(content.encode('utf8'))) + + signature = activitypub.Signature( + creator='%s#main-key' % user.remote_id, + created=activity_object['published'], + signatureValue=b64encode(signed_message).decode('utf8') + ) + + return activitypub.Create( + id=create_id, + actor=user.remote_id, + to=activity_object['to'], + cc=activity_object['cc'], + object=activity_object, + signature=signature, + ).serialize() + + + def to_delete_activity(self, user): + ''' notice of deletion ''' + return activitypub.Delete( + id=self.remote_id + '/activity', + actor=user.remote_id, + to=['%s/followers' % user.remote_id], + cc=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity(), + ).serialize() + + + def to_update_activity(self, user): + ''' wrapper for Updates to an activity ''' + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) + return activitypub.Update( + id=activity_id, + actor=user.remote_id, + to=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity() + ).serialize() From 7381536ad6f92be6bbea01e90f76691726daf727 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 14:27:26 -0800 Subject: [PATCH 07/34] circular import issues and added_by migration --- bookwyrm/goodreads_import.py | 4 +- .../migrations/0043_auto_20210204_2223.py | 23 ++ .../activitypub_mixin.py | 226 +++++++++++++++++- bookwyrm/models/activitypub_mixin/__init__.py | 1 - .../activitypub_mixin/activity_mixin.py | 25 -- .../models/activitypub_mixin/object_mixin.py | 94 -------- .../activitypub_mixin/ordered_collection.py | 115 --------- bookwyrm/models/book.py | 2 +- bookwyrm/templates/lists/curate.html | 2 +- bookwyrm/templates/lists/list.html | 4 +- bookwyrm/tests/models/test_list.py | 2 +- bookwyrm/tests/test_goodreads_import.py | 2 +- bookwyrm/tests/views/test_feed.py | 2 +- bookwyrm/tests/views/test_list.py | 16 +- bookwyrm/tests/views/test_reading.py | 2 +- bookwyrm/views/books.py | 4 +- bookwyrm/views/list.py | 6 +- bookwyrm/views/reading.py | 4 +- bookwyrm/views/shelf.py | 4 +- 19 files changed, 273 insertions(+), 265 deletions(-) create mode 100644 bookwyrm/migrations/0043_auto_20210204_2223.py rename bookwyrm/models/{activitypub_mixin => }/activitypub_mixin.py (52%) delete mode 100644 bookwyrm/models/activitypub_mixin/__init__.py delete mode 100644 bookwyrm/models/activitypub_mixin/activity_mixin.py delete mode 100644 bookwyrm/models/activitypub_mixin/object_mixin.py delete mode 100644 bookwyrm/models/activitypub_mixin/ordered_collection.py diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index 606705c8..3dcdc2f0 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -81,7 +81,7 @@ def handle_imported_book(user, item, include_reviews, privacy): return existing_shelf = models.ShelfBook.objects.filter( - book=item.book, added_by=user).exists() + book=item.book, user=user).exists() # shelve the book if it hasn't been shelved already if item.shelf and not existing_shelf: @@ -90,7 +90,7 @@ def handle_imported_book(user, item, include_reviews, privacy): user=user ) models.ShelfBook.objects.create( - book=item.book, shelf=desired_shelf, added_by=user) + book=item.book, shelf=desired_shelf, user=user) for read in item.reads: # check for an existing readthrough with the same dates diff --git a/bookwyrm/migrations/0043_auto_20210204_2223.py b/bookwyrm/migrations/0043_auto_20210204_2223.py new file mode 100644 index 00000000..b9c328ea --- /dev/null +++ b/bookwyrm/migrations/0043_auto_20210204_2223.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2021-02-04 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0042_auto_20210201_2108'), + ] + + operations = [ + migrations.RenameField( + model_name='listitem', + old_name='added_by', + new_name='user', + ), + migrations.RenameField( + model_name='shelfbook', + old_name='added_by', + new_name='user', + ), + ] diff --git a/bookwyrm/models/activitypub_mixin/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py similarity index 52% rename from bookwyrm/models/activitypub_mixin/activitypub_mixin.py rename to bookwyrm/models/activitypub_mixin.py index eef5ce2b..eee916bb 100644 --- a/bookwyrm/models/activitypub_mixin/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -2,20 +2,25 @@ from functools import reduce import json import operator +from base64 import b64encode +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 USER_AGENT +from bookwyrm.settings import USER_AGENT, PAGE_LENGTH from bookwyrm.signatures import make_signature, make_digest from bookwyrm.tasks import app -from .fields import ImageField, ManyToManyField +from bookwyrm.models.fields import ImageField, ManyToManyField class ActivitypubMixin: @@ -247,3 +252,218 @@ def execute_after_save(sender, instance, created, *args, **kwargs): if activity and user and user.local: instance.broadcast(activity, user) + + +class ObjectMixin(ActivitypubMixin): + ''' add this mixin for object models that are AP serializable ''' + + def save(self, *args, **kwargs): + ''' broadcast updated ''' + # first off, we want to save normally no matter what + super().save(*args, **kwargs) + + # we only want to handle updates, not newly created objects + if not self.id: + return + + # this will work for lists, shelves + user = self.user if hasattr(self, 'user') else None + if not user: + # users don't have associated users, they ARE users + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if isinstance(self, user_model): + user = self + # book data tracks last editor + elif hasattr(self, 'last_edited_by'): + user = self.last_edited_by + # again, if we don't know the user or they're remote, don't bother + if not user or not user.local: + return + + # is this a deletion? + if self.deleted: + activity = self.to_delete_activity(user) + else: + activity = self.to_update_activity(user) + self.broadcast(activity, user) + + + def to_create_activity(self, user, **kwargs): + ''' returns the object wrapped in a Create activity ''' + activity_object = self.to_activity(**kwargs) + + signature = None + create_id = self.remote_id + '/activity' + if 'content' in activity_object: + signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) + content = activity_object['content'] + signed_message = signer.sign(SHA256.new(content.encode('utf8'))) + + signature = activitypub.Signature( + creator='%s#main-key' % user.remote_id, + created=activity_object['published'], + signatureValue=b64encode(signed_message).decode('utf8') + ) + + return activitypub.Create( + id=create_id, + actor=user.remote_id, + to=activity_object['to'], + cc=activity_object['cc'], + object=activity_object, + signature=signature, + ).serialize() + + + def to_delete_activity(self, user): + ''' notice of deletion ''' + return activitypub.Delete( + id=self.remote_id + '/activity', + actor=user.remote_id, + to=['%s/followers' % user.remote_id], + cc=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity(), + ).serialize() + + + def to_update_activity(self, user): + ''' wrapper for Updates to an activity ''' + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) + return activitypub.Update( + id=activity_id, + actor=user.remote_id, + to=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity() + ).serialize() + + +class OrderedCollectionPageMixin(ObjectMixin): + ''' just the paginator utilities, so you don't HAVE to + override ActivitypubMixin's to_activity (ie, for outbox) ''' + @property + def collection_remote_id(self): + ''' this can be overriden if there's a special remote id, ie outbox ''' + return self.remote_id + + + def to_ordered_collection(self, queryset, \ + remote_id=None, page=False, collection_only=False, **kwargs): + ''' an ordered collection of whatevers ''' + if not queryset.ordered: + raise RuntimeError('queryset must be ordered') + + remote_id = remote_id or self.remote_id + if page: + return to_ordered_collection_page( + queryset, remote_id, **kwargs) + + if collection_only or not hasattr(self, 'activity_serializer'): + serializer = activitypub.OrderedCollection + activity = {} + else: + serializer = self.activity_serializer + # a dict from the model fields + activity = generate_activity(self) + + if remote_id: + activity['id'] = remote_id + + paginated = Paginator(queryset, PAGE_LENGTH) + # add computed fields specific to orderd collections + activity['totalItems'] = paginated.count + activity['first'] = '%s?page=1' % remote_id + activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) + + return serializer(**activity).serialize() + + +# pylint: disable=unused-argument +def to_ordered_collection_page( + queryset, remote_id, id_only=False, page=1, **kwargs): + ''' serialize and pagiante a queryset ''' + paginated = Paginator(queryset, PAGE_LENGTH) + + activity_page = paginated.page(page) + if id_only: + items = [s.remote_id for s in activity_page.object_list] + else: + items = [s.to_activity() for s in activity_page.object_list] + + prev_page = next_page = None + if activity_page.has_next(): + next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '%s?page=%d' % \ + (remote_id, activity_page.previous_page_number()) + return activitypub.OrderedCollectionPage( + id='%s?page=%s' % (remote_id, page), + partOf=remote_id, + orderedItems=items, + next=next_page, + prev=prev_page + ).serialize() + + +class OrderedCollectionMixin(OrderedCollectionPageMixin): + ''' extends activitypub models to work as ordered collections ''' + @property + def collection_queryset(self): + ''' usually an ordered collection model aggregates a different model ''' + raise NotImplementedError('Model must define collection_queryset') + + activity_serializer = activitypub.OrderedCollection + + def to_activity(self, **kwargs): + ''' an ordered collection of the specified model queryset ''' + return self.to_ordered_collection(self.collection_queryset, **kwargs) + + +class CollectionItemMixin(ActivitypubMixin): + ''' for items that are part of an (Ordered)Collection ''' + activity_serializer = activitypub.Add + object_field = collection_field = None + + def to_add_activity(self): + ''' AP for shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Add( + id='%s#add' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + def to_remove_activity(self): + ''' AP for un-shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Remove( + id='%s#remove' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + +class ActivitybMixin(ActivitypubMixin): + ''' add this mixin for models that are AP serializable ''' + + def save(self, *args, **kwargs): + ''' broadcast activity ''' + super().save(*args, **kwargs) + self.broadcast(self.to_activity(), self.user) + + def delete(self, *args, **kwargs): + ''' nevermind, undo that activity ''' + self.broadcast(self.to_undo_activity(), self.user) + super().delete(*args, **kwargs) + + + def to_undo_activity(self): + ''' undo an action ''' + return activitypub.Undo( + id='%s#undo' % self.remote_id, + actor=self.user.remote_id, + object=self.to_activity() + ).serialize() diff --git a/bookwyrm/models/activitypub_mixin/__init__.py b/bookwyrm/models/activitypub_mixin/__init__.py deleted file mode 100644 index b6e690fd..00000000 --- a/bookwyrm/models/activitypub_mixin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import * diff --git a/bookwyrm/models/activitypub_mixin/activity_mixin.py b/bookwyrm/models/activitypub_mixin/activity_mixin.py deleted file mode 100644 index fc74fe17..00000000 --- a/bookwyrm/models/activitypub_mixin/activity_mixin.py +++ /dev/null @@ -1,25 +0,0 @@ -''' activitypub model functionality ''' -from bookwyrm import activitypub -from . import ActivitypubMixin - -class ActivitybMixin(ActivitypubMixin): - ''' add this mixin for models that are AP serializable ''' - - def save(self, *args, **kwargs): - ''' broadcast activity ''' - super().save(*args, **kwargs) - self.broadcast(self.to_activity(), self.user) - - def delete(self, *args, **kwargs): - ''' nevermind, undo that activity ''' - self.broadcast(self.to_undo_activity(), self.user) - super().delete(*args, **kwargs) - - - def to_undo_activity(self): - ''' undo an action ''' - return activitypub.Undo( - id='%s#undo' % self.remote_id, - actor=self.user.remote_id, - object=self.to_activity() - ).serialize() diff --git a/bookwyrm/models/activitypub_mixin/object_mixin.py b/bookwyrm/models/activitypub_mixin/object_mixin.py deleted file mode 100644 index 2eddf813..00000000 --- a/bookwyrm/models/activitypub_mixin/object_mixin.py +++ /dev/null @@ -1,94 +0,0 @@ -''' activitypub objects like Person and Book''' -from base64 import b64encode -from uuid import uuid4 - -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 -from Crypto.Hash import SHA256 -from django.apps import apps - -from bookwyrm import activitypub -from . import ActivitypubMixin - - -class ObjectMixin(ActivitypubMixin): - ''' add this mixin for object models that are AP serializable ''' - - def save(self, *args, **kwargs): - ''' broadcast updated ''' - # first off, we want to save normally no matter what - super().save(*args, **kwargs) - - # we only want to handle updates, not newly created objects - if not self.id: - return - - # this will work for lists, shelves - user = self.user if hasattr(self, 'user') else None - if not user: - # users don't have associated users, they ARE users - user_model = apps.get_model('bookwyrm.User', require_ready=True) - if isinstance(self, user_model): - user = self - # book data tracks last editor - elif hasattr(self, 'last_edited_by'): - user = self.last_edited_by - # again, if we don't know the user or they're remote, don't bother - if not user or not user.local: - return - - # is this a deletion? - if self.deleted: - activity = self.to_delete_activity(user) - else: - activity = self.to_update_activity(user) - self.broadcast(activity, user) - - - def to_create_activity(self, user, **kwargs): - ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(**kwargs) - - signature = None - create_id = self.remote_id + '/activity' - if 'content' in activity_object: - signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) - content = activity_object['content'] - signed_message = signer.sign(SHA256.new(content.encode('utf8'))) - - signature = activitypub.Signature( - creator='%s#main-key' % user.remote_id, - created=activity_object['published'], - signatureValue=b64encode(signed_message).decode('utf8') - ) - - return activitypub.Create( - id=create_id, - actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], - object=activity_object, - signature=signature, - ).serialize() - - - def to_delete_activity(self, user): - ''' notice of deletion ''' - return activitypub.Delete( - id=self.remote_id + '/activity', - actor=user.remote_id, - to=['%s/followers' % user.remote_id], - cc=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity(), - ).serialize() - - - def to_update_activity(self, user): - ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (self.remote_id, uuid4()) - return activitypub.Update( - id=activity_id, - actor=user.remote_id, - to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() - ).serialize() diff --git a/bookwyrm/models/activitypub_mixin/ordered_collection.py b/bookwyrm/models/activitypub_mixin/ordered_collection.py deleted file mode 100644 index 954e0364..00000000 --- a/bookwyrm/models/activitypub_mixin/ordered_collection.py +++ /dev/null @@ -1,115 +0,0 @@ -''' lists of objects ''' -from django.core.paginator import Paginator - -from bookwyrm import activitypub -from bookwyrm.settings import PAGE_LENGTH -from . import ActivitypubMixin, ObjectMixin, generate_activity - - -class OrderedCollectionPageMixin(ObjectMixin): - ''' just the paginator utilities, so you don't HAVE to - override ActivitypubMixin's to_activity (ie, for outbox) ''' - @property - def collection_remote_id(self): - ''' this can be overriden if there's a special remote id, ie outbox ''' - return self.remote_id - - - def to_ordered_collection(self, queryset, \ - remote_id=None, page=False, collection_only=False, **kwargs): - ''' an ordered collection of whatevers ''' - if not queryset.ordered: - raise RuntimeError('queryset must be ordered') - - remote_id = remote_id or self.remote_id - if page: - return to_ordered_collection_page( - queryset, remote_id, **kwargs) - - if collection_only or not hasattr(self, 'activity_serializer'): - serializer = activitypub.OrderedCollection - activity = {} - else: - serializer = self.activity_serializer - # a dict from the model fields - activity = generate_activity(self) - - if remote_id: - activity['id'] = remote_id - - paginated = Paginator(queryset, PAGE_LENGTH) - # add computed fields specific to orderd collections - activity['totalItems'] = paginated.count - activity['first'] = '%s?page=1' % remote_id - activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) - - return serializer(**activity).serialize() - - -# pylint: disable=unused-argument -def to_ordered_collection_page( - queryset, remote_id, id_only=False, page=1, **kwargs): - ''' serialize and pagiante a queryset ''' - paginated = Paginator(queryset, PAGE_LENGTH) - - activity_page = paginated.page(page) - if id_only: - items = [s.remote_id for s in activity_page.object_list] - else: - items = [s.to_activity() for s in activity_page.object_list] - - prev_page = next_page = None - if activity_page.has_next(): - next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) - if activity_page.has_previous(): - prev_page = '%s?page=%d' % \ - (remote_id, activity_page.previous_page_number()) - return activitypub.OrderedCollectionPage( - id='%s?page=%s' % (remote_id, page), - partOf=remote_id, - orderedItems=items, - next=next_page, - prev=prev_page - ).serialize() - - -class OrderedCollectionMixin(OrderedCollectionPageMixin): - ''' extends activitypub models to work as ordered collections ''' - @property - def collection_queryset(self): - ''' usually an ordered collection model aggregates a different model ''' - raise NotImplementedError('Model must define collection_queryset') - - activity_serializer = activitypub.OrderedCollection - - def to_activity(self, **kwargs): - ''' an ordered collection of the specified model queryset ''' - return self.to_ordered_collection(self.collection_queryset, **kwargs) - - -class CollectionItemMixin(ActivitypubMixin): - ''' for items that are part of an (Ordered)Collection ''' - activity_serializer = activitypub.Add - object_field = collection_field = None - - def to_add_activity(self): - ''' AP for shelving a book''' - object_field = getattr(self, self.object_field) - collection_field = getattr(self, self.collection_field) - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=self.user.remote_id, - object=object_field.to_activity(), - target=collection_field.remote_id - ).serialize() - - def to_remove_activity(self): - ''' AP for un-shelving a book''' - object_field = getattr(self, self.object_field) - collection_field = getattr(self, self.collection_field) - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=self.user.remote_id, - object=object_field.to_activity(), - target=collection_field.remote_id - ).serialize() diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index ef1dd96d..f1f20830 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -7,7 +7,7 @@ from model_utils.managers import InheritanceManager from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .activitypub_mixin import ObjectMixin, OrderedCollectionPageMixin +from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/templates/lists/curate.html b/bookwyrm/templates/lists/curate.html index 20c8175d..a7e0fe79 100644 --- a/bookwyrm/templates/lists/curate.html +++ b/bookwyrm/templates/lists/curate.html @@ -23,7 +23,7 @@ {% include 'snippets/book_titleby.html' with book=item.book %} - {% include 'snippets/username.html' with user=item.added_by %} + {% include 'snippets/username.html' with user=item.user %}
diff --git a/bookwyrm/templates/lists/list.html b/bookwyrm/templates/lists/list.html index 213f9dca..7899d593 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -31,9 +31,9 @@