1
0
Fork 0

Merge branch 'main' into review-rate

This commit is contained in:
Mouse Reeve 2021-02-25 10:17:52 -08:00
commit ed7c13531f
97 changed files with 1505 additions and 1261 deletions

View file

@ -6,6 +6,7 @@ import operator
import logging
from uuid import uuid4
import requests
from requests.exceptions import HTTPError, SSLError
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
@ -156,10 +157,14 @@ class ActivitypubMixin:
return recipients
def to_activity(self):
def to_activity_dataclass(self):
''' convert from a model to an activity '''
activity = generate_activity(self)
return self.activity_serializer(**activity).serialize()
return self.activity_serializer(**activity)
def to_activity(self, **kwargs): # pylint: disable=unused-argument
''' convert from a model to a json activity '''
return self.to_activity_dataclass().serialize()
class ObjectMixin(ActivitypubMixin):
@ -187,7 +192,7 @@ class ObjectMixin(ActivitypubMixin):
try:
software = None
# do we have a "pure" activitypub version of this for mastodon?
# do we have a "pure" activitypub version of this for mastodon?
if hasattr(self, 'pure_content'):
pure_activity = self.to_create_activity(user, pure=True)
self.broadcast(pure_activity, user, software='other')
@ -195,7 +200,7 @@ class ObjectMixin(ActivitypubMixin):
# sends to BW only if we just did a pure version for masto
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software)
except KeyError:
except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
return
@ -224,26 +229,26 @@ class ObjectMixin(ActivitypubMixin):
def to_create_activity(self, user, **kwargs):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(**kwargs)
activity_object = self.to_activity_dataclass(**kwargs)
signature = None
create_id = self.remote_id + '/activity'
if 'content' in activity_object and activity_object['content']:
if hasattr(activity_object, 'content') and activity_object.content:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
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'],
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'],
to=activity_object.to,
cc=activity_object.cc,
object=activity_object,
signature=signature,
).serialize()
@ -256,7 +261,7 @@ class ObjectMixin(ActivitypubMixin):
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity(),
object=self,
).serialize()
@ -267,7 +272,7 @@ class ObjectMixin(ActivitypubMixin):
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
object=self
).serialize()
@ -308,7 +313,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
activity['first'] = '%s?page=1' % remote_id
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
return serializer(**activity).serialize()
return serializer(**activity)
class OrderedCollectionMixin(OrderedCollectionPageMixin):
@ -320,9 +325,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
activity_serializer = activitypub.OrderedCollection
def to_activity_dataclass(self, **kwargs):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
return self.to_ordered_collection(
self.collection_queryset, **kwargs).serialize()
class CollectionItemMixin(ActivitypubMixin):
@ -359,7 +368,7 @@ class CollectionItemMixin(ActivitypubMixin):
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
object=object_field,
target=collection_field.remote_id
).serialize()
@ -370,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin):
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
object=object_field,
target=collection_field.remote_id
).serialize()
@ -399,7 +408,7 @@ class ActivityMixin(ActivitypubMixin):
return activitypub.Undo(
id='%s#undo' % self.remote_id,
actor=user.remote_id,
object=self.to_activity()
object=self,
).serialize()
@ -440,7 +449,7 @@ def broadcast_task(sender_id, activity, recipients):
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e:
except (HTTPError, SSLError) as e:
logger.exception(e)
@ -472,7 +481,7 @@ def sign_and_send(sender, data, destination):
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
@ -480,7 +489,7 @@ def to_ordered_collection_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]
items = [s.to_activity(pure=pure) for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
@ -494,4 +503,4 @@ def to_ordered_collection_page(
orderedItems=items,
next=next_page,
prev=prev_page
).serialize()
)

View file

@ -122,13 +122,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
return None
related_model = self.related_model
if isinstance(value, dict) and value.get('id'):
if hasattr(value, 'id') and value.id:
if not self.load_remote:
# only look in the local database
return related_model.find_existing(value)
return related_model.find_existing(value.serialize())
# this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
return value.to_model(model=related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
@ -139,7 +138,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(related_model, value)
return activitypub.resolve_remote_id(value, model=related_model)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
@ -280,7 +279,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(self.related_model, remote_id)
activitypub.resolve_remote_id(
remote_id, model=self.related_model)
)
return items
@ -317,7 +317,8 @@ class TagField(ManyToManyField):
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(self.related_model, link.href)
activitypub.resolve_remote_id(
link.href, model=self.related_model)
)
return items
@ -366,8 +367,8 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
if hasattr(image_slug, 'url'):
url = image_slug.url
elif isinstance(image_slug, str):
url = image_slug
else:

View file

@ -68,7 +68,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField('User', related_name='endorsers')
activity_serializer = activitypub.AddListItem
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'book_list'

View file

@ -1,11 +1,11 @@
''' defines relationships between users '''
from django.apps import apps
from django.db import models, transaction
from django.db import models, transaction, IntegrityError
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import generate_activity
from .base_model import BookWyrmModel
from . import fields
@ -56,11 +56,30 @@ class UserRelationship(BookWyrmModel):
return '%s#%s/%d' % (base_path, status, self.id)
class UserFollows(ActivitypubMixin, UserRelationship):
class UserFollows(ActivityMixin, UserRelationship):
''' Following a user '''
status = 'follows'
activity_serializer = activitypub.Follow
def to_activity(self):
''' overrides default to manually set serializer '''
return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs):
''' really really don't let a user follow someone who blocked them '''
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@classmethod
def from_request(cls, follow_request):
@ -79,31 +98,36 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
def save(self, *args, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
try:
UserFollows.objects.get(
# don't create a request if a follow already exists
if UserFollows.objects.filter(
user_subject=self.user_subject,
user_object=self.user_object,
)
# blocking in either direction is a no-go
UserBlocks.objects.get(
user_subject=self.user_subject,
user_object=self.user_object,
)
UserBlocks.objects.get(
user_subject=self.user_object,
user_object=self.user_subject,
)
return None
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs)
).exists():
raise IntegrityError()
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
manually_approves = self.user_object.manually_approves_followers
if not manually_approves:
self.accept()
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' \
if self.user_object.manually_approves_followers else 'FOLLOW'
notification_type = 'FOLLOW_REQUEST' if \
manually_approves else 'FOLLOW'
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
@ -114,28 +138,30 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
def accept(self):
''' turn this request into the real deal'''
user = self.user_object
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.broadcast(activity, user)
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
self.broadcast(activity, user)
def reject(self):
''' generate a Reject for this follow request '''
user = self.user_object
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.broadcast(activity, self.user_object)
self.delete()
self.broadcast(activity, user)
class UserBlocks(ActivityMixin, UserRelationship):
@ -143,20 +169,15 @@ class UserBlocks(ActivityMixin, UserRelationship):
status = 'blocks'
activity_serializer = activitypub.Block
def save(self, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
super().save(*args, **kwargs)
@receiver(models.signals.post_save, sender=UserBlocks)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
UserFollows.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()
UserFollows.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()

View file

@ -57,7 +57,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
activity_serializer = activitypub.AddBook
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'shelf'

View file

@ -84,6 +84,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
related_status=self,
)
def delete(self, *args, **kwargs):#pylint: disable=unused-argument
''' "delete" a status '''
if hasattr(self, 'boosted_status'):
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
return
self.deleted = True
self.deleted_date = timezone.now()
self.save()
@property
def recipients(self):
''' tagged users who definitely need to get this status in broadcast '''
@ -96,6 +106,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
if activity.type == 'Announce':
# keep it if the booster or the boosted are local
boosted = activitypub.resolve_remote_id(activity.object, save=False)
return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type
if activity.type != 'Note':
return False
if cls.objects.filter(
@ -106,8 +122,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
user_model = apps.get_model('bookwyrm.User', require_ready=True)
for tag in tags:
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if user_model.objects.filter(
remote_id=tag, local=True).exists():
# we found a mention of a known use boost
@ -139,9 +155,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
remote_id='%s/replies' % self.remote_id,
collection_only=True,
**kwargs
)
).serialize()
def to_activity(self, pure=False):# pylint: disable=arguments-differ
def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
@ -149,25 +165,29 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
url=self.remote_id,
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
).serialize()
activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
)
activity = ActivitypubMixin.to_activity_dataclass(self)
activity.replies = self.to_replies()
# "pure" serialization for non-bookwyrm instances
if pure and hasattr(self, 'pure_content'):
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
activity.content = self.pure_content
if hasattr(activity, 'name'):
activity.name = self.pure_name
activity.type = self.pure_type
activity.attachment = [
image_serializer(b.cover, b.alt_text) \
for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book') and self.book.cover:
activity['attachment'].append(
activity.attachment.append(
image_serializer(self.book.cover, self.book.alt_text)
)
return activity
def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' json serialized activitypub class '''
return self.to_activity_dataclass(pure=pure).serialize()
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
@ -282,7 +302,7 @@ class Boost(ActivityMixin, Status):
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Boost
activity_serializer = activitypub.Announce
def save(self, *args, **kwargs):
''' save and notify '''

View file

@ -1,6 +1,7 @@
''' models for storing different kinds of Activities '''
import urllib.parse
from django.apps import apps
from django.db import models
from bookwyrm import activitypub
@ -15,17 +16,15 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(
identifier=identifier
).order_by('-updated_date')
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def books(self):
''' count of books associated with this tag '''
edition_model = apps.get_model('bookwyrm.Edition', require_ready=True)
return edition_model.objects.filter(
usertag__tag__identifier=self.identifier
).order_by('-created_date').distinct()
collection_queryset = books
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
@ -50,7 +49,7 @@ class UserTag(CollectionItemMixin, BookWyrmModel):
tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'tag'

View file

@ -6,11 +6,10 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
@ -113,6 +112,16 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
''' the user queryset filtered for the context of the logged in user '''
queryset = cls.objects.filter(is_active=True)
if viewer.is_authenticated:
queryset = queryset.exclude(
blocks=viewer
)
return queryset
def to_outbox(self, filter_type=None, **kwargs):
''' an ordered collection of statuses '''
if filter_type:
@ -131,7 +140,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
privacy__in=['public', 'unlisted'],
).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \
collection_only=True, remote_id=self.outbox, **kwargs)
collection_only=True, remote_id=self.outbox, **kwargs).serialize()
def to_following_activity(self, **kwargs):
''' activitypub following list '''
@ -172,15 +181,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
if self.id or not self.local:
return super().save(*args, **kwargs)
# this user already exists, no need to populate fields
if not created:
super().save(*args, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
set_remote_server.delay(self.id)
return
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
@ -188,7 +205,32 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
return super().save(*args, **kwargs)
# an id needs to be set before we can proceed with related models
super().save(*args, **kwargs)
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % self.remote_id)
self.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=self,
editable=False
).save(broadcast=False)
@property
def local_path(self):
@ -280,42 +322,6 @@ class AnnualGoal(BookWyrmModel):
finish_date__year__gte=self.year).count()
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not created:
return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save(broadcast=False)
@app.task
def set_remote_server(user_id):
''' figure out the user's remote server in the background '''
@ -323,7 +329,7 @@ def set_remote_server(user_id):
actor_parts = urlparse(user.remote_id)
user.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
user.save()
user.save(broadcast=False)
if user.bookwyrm_user:
get_remote_reviews.delay(user.outbox)
@ -337,19 +343,24 @@ def get_or_create_remote_server(domain):
except FederatedServer.DoesNotExist:
pass
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
raise ConnectorException()
data = get_data(nodeinfo_url)
application_type = data.get('software', {}).get('name')
application_version = data.get('software', {}).get('version')
except ConnectorException:
application_type = application_version = None
data = get_data(nodeinfo_url)
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
application_type=application_type,
application_version=application_version,
)
return server
@ -364,4 +375,4 @@ def get_remote_reviews(outbox):
for activity in data['orderedItems']:
if not activity['type'] == 'Review':
continue
activitypub.Review(**activity).to_model(Review)
activitypub.Review(**activity).to_model()