From dfb5c396b031986b5e88364b92346d560b56a427 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 4 Feb 2021 10:47:03 -0800 Subject: [PATCH] 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