Merge pull request #625 from mouse-reeve/inbox-refactor

Inbox refactor
This commit is contained in:
Mouse Reeve 2021-02-24 13:34:59 -08:00 committed by GitHub
commit 0ecfff0f16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 958 additions and 887 deletions

View file

@ -2,13 +2,12 @@
import inspect
import sys
from .base_activity import ActivityEncoder, Signature
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey
@ -16,10 +15,15 @@ from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, AddBook, AddListItem, Remove
from .verbs import Add, Remove
from .verbs import Announce, Like
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')}
def parse(activity_json):
''' figure out what activity this is and parse it '''
return naive_parse(activity_objects, activity_json)

View file

@ -40,6 +40,20 @@ class Signature:
signatureValue: str
type: str = 'RsaSignature2017'
def naive_parse(activity_objects, activity_json, serializer=None):
''' this navigates circular import issues '''
if not serializer:
if activity_json.get('publicKeyPem'):
# ugh
activity_json['type'] = 'PublicKey'
try:
activity_type = activity_json['type']
serializer = activity_objects[activity_type]
except KeyError as e:
raise ActivitySerializerError(e)
return serializer(activity_objects=activity_objects, **activity_json)
@dataclass(init=False)
class ActivityObject:
@ -47,13 +61,30 @@ class ActivityObject:
id: str
type: str
def __init__(self, **kwargs):
def __init__(self, activity_objects=None, **kwargs):
''' this lets you pass in an object with fields that aren't in the
dataclass, which it ignores. Any field in the dataclass is required or
has a default value '''
for field in fields(self):
try:
value = kwargs[field.name]
if value in (None, MISSING):
raise KeyError()
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
is_subclass = False
# serialize a model obj
if hasattr(value, 'to_activity'):
value = value.to_activity()
# parse a dict into the appropriate activity
elif is_subclass and isinstance(value, dict):
if activity_objects:
value = naive_parse(activity_objects, value)
else:
value = naive_parse(
activity_objects, value, serializer=field.type)
except KeyError:
if field.default == MISSING and \
field.default_factory == MISSING:
@ -63,31 +94,29 @@ class ActivityObject:
setattr(self, field.name, value)
def to_model(self, model, instance=None, save=True):
def to_model(self, model=None, instance=None, allow_create=True, save=True):
''' convert from an activity to a model instance '''
if self.type != model.activity_serializer.type:
raise ActivitySerializerError(
'Wrong activity type "%s" for activity of type "%s"' % \
(model.activity_serializer.type,
self.type)
)
model = model or get_model_from_type(self.type)
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
# only reject statuses if we're potentially creating them
if allow_create and \
hasattr(model, 'ignore_activity') and \
model.ignore_activity(self):
return None
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# check for an existing instance
instance = instance or model.find_existing(self.serialize())
# check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model()
if not instance and not allow_create:
# so that we don't create when we want to delete or update
return None
instance = instance or model()
for field in instance.simple_fields:
field.set_field_from_activity(instance, self)
try:
field.set_field_from_activity(instance, self)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save
# too early and jank up users
@ -139,7 +168,14 @@ class ActivityObject:
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
except TypeError:
pass
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data
@ -182,7 +218,7 @@ def set_related_field(
getattr(model_field, 'activitypub_field'),
instance.remote_id
)
item = activity.to_model(model)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
@ -191,11 +227,24 @@ def set_related_field(
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True):
def get_model_from_type(activity_type):
''' given the activity, what type of model '''
models = apps.get_models()
model = [m for m in models if hasattr(m, 'activity_serializer') and \
hasattr(m.activity_serializer, 'type') and \
m.activity_serializer.type == activity_type]
if not model:
raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type)
return model[0]
def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
''' take a remote_id and return an instance, creating if necessary '''
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
if model:# a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
# load the data and create the object
try:
@ -204,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
# determine the model implicitly, if not provided
if not model:
model = get_model_from_type(data.get('type'))
# check for existing items with shared unique identifiers
if not result:
result = model.find_existing(data)
if result and not refresh:
return result
result = model.find_existing(data)
if result and not refresh:
return result
item = model.activity_serializer(**data)
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save)
return item.to_model(model=model, instance=result, save=save)

View file

@ -67,4 +67,4 @@ class Author(ActivityObject):
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'
type: str = 'Author'

View file

@ -1,20 +0,0 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View file

@ -1,6 +1,7 @@
''' note serializer and children thereof '''
from dataclasses import dataclass, field
from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link
from .image import Image
@ -8,10 +9,13 @@ from .image import Image
@dataclass(init=False)
class Tombstone(ActivityObject):
''' the placeholder for a deleted status '''
published: str
deleted: str
type: str = 'Tombstone'
def to_model(self, *args, **kwargs):
''' this should never really get serialized, just searched for '''
model = apps.get_model('bookwyrm.Status')
return model.find_existing_by_remote_id(self.id)
@dataclass(init=False)
class Note(ActivityObject):

View file

@ -17,6 +17,7 @@ class OrderedCollection(ActivityObject):
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
''' an ordered collection with privacy settings '''
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
@ -38,6 +39,6 @@ class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity '''
partOf: str
orderedItems: List
next: str
prev: str
next: str = None
prev: str = None
type: str = 'OrderedCollectionPage'

View file

@ -9,7 +9,7 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=True,
def __init__(self, data, encoder=ActivityEncoder, safe=False,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:

View file

@ -1,10 +1,12 @@
''' undo wrapper activity '''
from dataclasses import dataclass
from typing import List
from django.apps import apps
from .base_activity import ActivityObject, Signature
from .base_activity import ActivityObject, Signature, resolve_remote_id
from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
@ -12,6 +14,10 @@ class Verb(ActivityObject):
actor: str
object: ActivityObject
def action(self):
''' usually we just want to save, this can be overridden as needed '''
self.object.to_model()
@dataclass(init=False)
class Create(Verb):
@ -29,6 +35,12 @@ class Delete(Verb):
cc: List
type: str = 'Delete'
def action(self):
''' find and delete the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Update(Verb):
@ -36,29 +48,60 @@ class Update(Verb):
to: List
type: str = 'Update'
def action(self):
''' update a model instance from the dataclass '''
self.object.to_model(allow_create=False)
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
def action(self):
''' find and remove the activity object '''
# this is so hacky but it does make it work....
# (because you Reject a request and Undo a follow
model = None
if self.object.type == 'Follow':
model = apps.get_model('bookwyrm.UserFollows')
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
object: str
type: str = 'Follow'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
object: str
type: str = 'Block'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Accept(Verb):
''' Accept activity '''
object: Follow
type: str = 'Accept'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.accept()
@dataclass(init=False)
class Reject(Verb):
@ -66,32 +109,60 @@ class Reject(Verb):
object: Follow
type: str = 'Reject'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Add):
'''Add activity that's aware of the book obj '''
object: Edition
type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None
order: int = 0
approved: bool = True
def action(self):
''' add obj to collection '''
target = resolve_remote_id(self.target, refresh=False)
# we want to related field that isn't the book, this is janky af sorry
model = [t for t in type(target)._meta.related_objects \
if t.name != 'edition'][0].related_model
self.to_model(model=model)
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
target: ActivityObject
type: str = 'Remove'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Like(Verb):
''' a user faving an object '''
object: str
type: str = 'Like'
def action(self):
''' like '''
self.to_model()
@dataclass(init=False)
class Announce(Verb):
''' boosting a status '''
object: str
type: str = 'Announce'
def action(self):
''' boost '''
self.to_model()

View file

@ -127,7 +127,7 @@ class AbstractConnector(AbstractMinimalConnector):
# create activitypub object
work_activity = activitypub.Work(**work_data)
# this will dedupe automatically
work = work_activity.to_model(models.Work)
work = work_activity.to_model(model=models.Work)
for author in self.get_authors_from_data(data):
work.authors.add(author)
@ -141,7 +141,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(models.Edition)
edition = edition_activity.to_model(model=models.Edition)
edition.connector = self.connector
edition.save()
@ -168,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data)
# this will dedupe
return activity.to_model(models.Author)
return activity.to_model(model=models.Author)
@abstractmethod

View file

@ -7,7 +7,7 @@ class Connector(AbstractMinimalConnector):
''' this is basically just for search '''
def get_or_create_book(self, remote_id):
edition = activitypub.resolve_remote_id(models.Edition, remote_id)
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
work = edition.parent_work
work.default_edition = work.get_default_edition()
work.save()

View file

@ -1,360 +0,0 @@
''' handles all of the activity coming in to the server '''
import json
from urllib.parse import urldefrag
import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@csrf_exempt
@require_POST
def inbox(request, username):
''' incoming activitypub events '''
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
return shared_inbox(request)
@csrf_exempt
@require_POST
def shared_inbox(request):
''' incoming activitypub events '''
try:
resp = request.body
activity = json.loads(resp)
if isinstance(activity, str):
activity = json.loads(activity)
activity_object = activity['object']
except (json.decoder.JSONDecodeError, KeyError):
return HttpResponseBadRequest()
if not has_valid_signature(request, activity):
if activity['type'] == 'Delete':
# Pretend that unauth'd deletes succeed. Auth may be failing because
# the resource or owner of the resource might have been deleted.
return HttpResponse()
return HttpResponse(status=401)
# if this isn't a file ripe for refactor, I don't know what is.
handlers = {
'Follow': handle_follow,
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Block': handle_block,
'Create': {
'BookList': handle_create_list,
'Note': handle_create_status,
'Article': handle_create_status,
'Review': handle_create_status,
'Comment': handle_create_status,
'Quotation': handle_create_status,
},
'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
'Edition': handle_add,
},
'Undo': {
'Follow': handle_unfollow,
'Like': handle_unfavorite,
'Announce': handle_unboost,
'Block': handle_unblock,
},
'Update': {
'Person': handle_update_user,
'Edition': handle_update_edition,
'Work': handle_update_work,
'BookList': handle_update_list,
},
}
activity_type = activity['type']
handler = handlers.get(activity_type, None)
if isinstance(handler, dict):
handler = handler.get(activity_object['type'], None)
if not handler:
return HttpResponseNotFound()
handler.delay(activity)
return HttpResponse()
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
key_actor = urldefrag(signature.key_id).url
if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.")
remote_user = activitypub.resolve_remote_id(models.User, key_actor)
if not remote_user:
return False
try:
signature.verify(remote_user.key_pair.public_key, request)
except ValueError:
old_key = remote_user.key_pair.public_key
remote_user = activitypub.resolve_remote_id(
models.User, remote_user.remote_id, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True
@app.task
def handle_follow(activity):
''' someone wants to follow a local user '''
try:
relationship = activitypub.Follow(
**activity
).to_model(models.UserFollowRequest)
except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
relationship = models.UserFollowRequest.objects.get(
remote_id=activity['id']
)
# send the accept normally for a duplicate request
if not relationship.user_object.manually_approves_followers:
relationship.accept()
@app.task
def handle_unfollow(activity):
''' unfollow a local user '''
obj = activity['object']
requester = activitypub.resolve_remote_id(models.User, obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist
to_unfollow.followers.remove(requester)
@app.task
def handle_follow_accept(activity):
''' hurray, someone remote accepted a follow request '''
# figure out who they want to follow
requester = models.User.objects.get(remote_id=activity['object']['actor'])
# figure out who they are
accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
try:
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=accepter
)
request.delete()
except models.UserFollowRequest.DoesNotExist:
pass
accepter.followers.add(requester)
@app.task
def handle_follow_reject(activity):
''' someone is rejecting a follow request '''
requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=rejecter
)
request.delete()
#raises models.UserFollowRequest.DoesNotExist
@app.task
def handle_block(activity):
''' blocking a user '''
# create "block" databse entry
activitypub.Block(**activity).to_model(models.UserBlocks)
# the removing relationships is handled in model save
@app.task
def handle_unblock(activity):
''' undoing a block '''
try:
block_id = activity['object']['id']
except KeyError:
return
try:
block = models.UserBlocks.objects.get(remote_id=block_id)
except models.UserBlocks.DoesNotExist:
return
block.delete()
@app.task
def handle_create_list(activity):
''' a new list '''
activity = activity['object']
activitypub.BookList(**activity).to_model(models.List)
@app.task
def handle_update_list(activity):
''' update a list '''
try:
book_list = models.List.objects.get(remote_id=activity['object']['id'])
except models.List.DoesNotExist:
book_list = None
activitypub.BookList(
**activity['object']).to_model(models.List, instance=book_list)
@app.task
def handle_create_status(activity):
''' someone did something, good on them '''
# deduplicate incoming activities
activity = activity['object']
status_id = activity.get('id')
if models.Status.objects.filter(remote_id=status_id).count():
return
try:
serializer = activitypub.activity_objects[activity['type']]
except KeyError:
return
activity = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return
status = activity.to_model(model)
if not status:
# it was discarded because it's not a bookwyrm type
return
@app.task
def handle_delete_status(activity):
''' remove a status '''
try:
status_id = activity['object']['id']
except TypeError:
# this isn't a great fix, because you hit this when mastadon
# is trying to delete a user.
return
try:
status = models.Status.objects.get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
models.Notification.objects.filter(related_status=status).all().delete()
status_builder.delete_status(status)
@app.task
def handle_favorite(activity):
''' approval of your good good post '''
fav = activitypub.Like(**activity)
# we dont know this status, we don't care about this status
if not models.Status.objects.filter(remote_id=fav.object).exists():
return
fav = fav.to_model(models.Favorite)
if fav.user.local:
return
@app.task
def handle_unfavorite(activity):
''' approval of your good good post '''
like = models.Favorite.objects.filter(
remote_id=activity['object']['id']
).first()
if not like:
return
like.delete()
@app.task
def handle_boost(activity):
''' someone gave us a boost! '''
try:
activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
@app.task
def handle_unboost(activity):
''' someone gave us a boost! '''
boost = models.Boost.objects.filter(
remote_id=activity['object']['id']
).first()
if boost:
boost.delete()
@app.task
def handle_add(activity):
''' putting a book on a shelf '''
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddListItem(**activity).to_model(models.ListItem)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddBook(**activity).to_model(models.UserTag)
return
except activitypub.ActivitySerializerError:
pass
@app.task
def handle_update_user(activity):
''' receive an updated user Person activity object '''
try:
user = models.User.objects.get(remote_id=activity['object']['id'])
except models.User.DoesNotExist:
# who is this person? who cares
return
activitypub.Person(
**activity['object']
).to_model(models.User, instance=user)
# model save() happens in the to_model function
@app.task
def handle_update_edition(activity):
''' a remote instance changed a book (Document) '''
activitypub.Edition(**activity['object']).to_model(models.Edition)
@app.task
def handle_update_work(activity):
''' a remote instance changed a book (Document) '''
activitypub.Work(**activity['object']).to_model(models.Work)

View file

@ -157,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):
@ -188,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')
@ -196,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
@ -225,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()
@ -257,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()
@ -268,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()
@ -283,7 +287,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, collection_only=False, **kwargs):
'pure=pure, '' an ordered collection of whatevers '''
''' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
@ -309,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):
@ -321,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):
@ -360,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()
@ -371,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()
@ -400,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()
@ -495,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

@ -5,6 +5,7 @@ from django.db.models import Q
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import generate_activity
from .base_model import BookWyrmModel
from . import fields
@ -55,10 +56,13 @@ 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 '''
@ -73,7 +77,9 @@ class UserFollows(ActivitypubMixin, UserRelationship):
)
).exists():
raise IntegrityError()
super().save(*args, **kwargs)
# 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):
@ -109,16 +115,19 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
)
).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,
@ -129,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):

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 '''
@ -266,7 +286,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

@ -140,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 '''
@ -375,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()

View file

@ -79,7 +79,7 @@ class BaseActivity(TestCase):
def test_resolve_remote_id(self):
''' look up or load remote data '''
# existing item
result = resolve_remote_id(models.User, 'http://example.com/a/b')
result = resolve_remote_id('http://example.com/a/b', model=models.User)
self.assertEqual(result, self.user)
# remote item
@ -91,7 +91,7 @@ class BaseActivity(TestCase):
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = resolve_remote_id(
models.User, 'https://example.com/user/mouse')
'https://example.com/user/mouse', model=models.User)
self.assertIsInstance(result, models.User)
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
@ -100,46 +100,8 @@ class BaseActivity(TestCase):
''' catch mismatch between activity type and model type '''
instance = ActivityObject(id='a', type='b')
with self.assertRaises(ActivitySerializerError):
instance.to_model(models.User)
instance.to_model(model=models.User)
def test_to_model_simple_fields(self):
''' test setting simple fields '''
self.assertIsNone(self.user.name)
activity = activitypub.Person(
id=self.user.remote_id,
name='New Name',
preferredUsername='mouse',
inbox='http://www.com/',
outbox='http://www.com/',
followers='',
summary='',
publicKey=None,
endpoints={},
)
activity.to_model(models.User, self.user)
self.assertEqual(self.user.name, 'New Name')
def test_to_model_foreign_key(self):
''' test setting one to one/foreign key '''
activity = activitypub.Person(
id=self.user.remote_id,
name='New Name',
preferredUsername='mouse',
inbox='http://www.com/',
outbox='http://www.com/',
followers='',
summary='',
publicKey=self.user.key_pair.to_activity(),
endpoints={},
)
activity.publicKey['publicKeyPem'] = 'hi im secure'
activity.to_model(models.User, self.user)
self.assertEqual(self.user.key_pair.public_key, 'hi im secure')
@responses.activate
def test_to_model_image(self):
@ -152,9 +114,15 @@ class BaseActivity(TestCase):
outbox='http://www.com/',
followers='',
summary='',
publicKey=None,
publicKey={
'id': 'hi',
'owner': self.user.remote_id,
'publicKeyPem': 'hi'},
endpoints={},
icon={'url': 'http://www.example.com/image.jpg'}
icon={
'type': 'Image',
'url': 'http://www.example.com/image.jpg'
}
)
responses.add(
@ -169,9 +137,10 @@ class BaseActivity(TestCase):
# this would trigger a broadcast because it's a local user
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
activity.to_model(models.User, self.user)
self.assertIsNotNone(self.user.avatar.name)
activity.to_model(model=models.User, instance=self.user)
self.assertIsNotNone(self.user.avatar.file)
self.assertEqual(self.user.name, 'New Name')
self.assertEqual(self.user.key_pair.public_key, 'hi')
def test_to_model_many_to_many(self):
''' annoying that these all need special handling '''
@ -202,7 +171,7 @@ class BaseActivity(TestCase):
},
]
)
update_data.to_model(models.Status, instance=status)
update_data.to_model(model=models.Status, instance=status)
self.assertEqual(status.mention_users.first(), self.user)
self.assertEqual(status.mention_books.first(), book)
@ -239,7 +208,7 @@ class BaseActivity(TestCase):
# sets the celery task call to the function call
with patch(
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
update_data.to_model(models.Status, instance=status)
update_data.to_model(model=models.Status, instance=status)
self.assertIsNone(status.attachments.first())

View file

@ -25,7 +25,7 @@ class Person(TestCase):
def test_user_to_model(self):
activity = activitypub.Person(**self.user_data)
with patch('bookwyrm.models.user.set_remote_server.delay'):
user = activity.to_model(models.User)
user = activity.to_model(model=models.User)
self.assertEqual(user.username, 'mouse@example.com')
self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
self.assertFalse(user.local)

View file

@ -46,7 +46,7 @@ class Quotation(TestCase):
def test_activity_to_model(self):
''' create a model instance from an activity object '''
activity = activitypub.Quotation(**self.status_data)
quotation = activity.to_model(models.Quotation)
quotation = activity.to_model(model=models.Quotation)
self.assertEqual(quotation.book, self.book)
self.assertEqual(quotation.user, self.user)

View file

@ -92,11 +92,26 @@ class Openlibrary(TestCase):
responses.add(
responses.GET,
'https://openlibrary.org/authors/OL382982A',
json={'hi': 'there'},
json={
"name": "George Elliott",
"personal_name": "George Elliott",
"last_modified": {
"type": "/type/datetime",
"value": "2008-08-31 10:09:33.413686"
},
"key": "/authors/OL453734A",
"type": {
"key": "/type/author"
},
"id": 1259965,
"revision": 2
},
status=200)
results = self.connector.get_authors_from_data(self.work_data)
for result in results:
self.assertIsInstance(result, models.Author)
result = list(results)[0]
self.assertIsInstance(result, models.Author)
self.assertEqual(result.name, 'George Elliott')
self.assertEqual(result.openlibrary_key, 'OL453734A')
def test_get_cover_url(self):
@ -201,8 +216,11 @@ class Openlibrary(TestCase):
'https://openlibrary.org/authors/OL382982A',
json={'hi': 'there'},
status=200)
result = self.connector.create_edition_from_data(
work, self.edition_data)
with patch('bookwyrm.connectors.openlibrary.Connector.' \
'get_authors_from_data') as mock:
mock.return_value = []
result = self.connector.create_edition_from_data(
work, self.edition_data)
self.assertEqual(result.parent_work, work)
self.assertEqual(result.title, 'Sabriel')
self.assertEqual(result.isbn_10, '0060273224')

View file

@ -30,6 +30,12 @@ class ActivitypubMixins(TestCase):
outbox='https://example.com/users/rat/outbox',
)
self.object_mock = {
'to': 'to field', 'cc': 'cc field',
'content': 'hi', 'id': 'bip', 'type': 'Test',
'published': '2020-12-04T17:52:22.623807+00:00',
}
# ActivitypubMixin
def test_to_activity(self):
@ -290,40 +296,12 @@ class ActivitypubMixins(TestCase):
id=1, user=self.local_user, deleted=True).save()
def test_to_create_activity(self):
''' wrapper for ActivityPub "create" action '''
object_activity = {
'to': 'to field', 'cc': 'cc field',
'content': 'hi',
'published': '2020-12-04T17:52:22.623807+00:00',
}
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: object_activity
)
activity = ObjectMixin.to_create_activity(
mock_self, self.local_user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['to'], 'to field')
self.assertEqual(activity['cc'], 'cc field')
self.assertEqual(activity['object'], object_activity)
self.assertEqual(
activity['signature'].creator,
'%s#main-key' % self.local_user.remote_id
)
def test_to_delete_activity(self):
''' wrapper for Delete activity '''
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
lambda *args: self.object_mock
)
activity = ObjectMixin.to_delete_activity(
mock_self, self.local_user)
@ -346,7 +324,7 @@ class ActivitypubMixins(TestCase):
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
lambda *args: self.object_mock
)
activity = ObjectMixin.to_update_activity(
mock_self, self.local_user)
@ -361,7 +339,7 @@ class ActivitypubMixins(TestCase):
self.assertEqual(
activity['to'],
['https://www.w3.org/ns/activitystreams#Public'])
self.assertEqual(activity['object'], {})
self.assertIsInstance(activity['object'], dict)
# Activity mixin
@ -370,7 +348,7 @@ class ActivitypubMixins(TestCase):
MockSelf = namedtuple('Self', ('remote_id', 'to_activity', 'user'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {},
lambda *args: self.object_mock,
self.local_user,
)
activity = ActivityMixin.to_undo_activity(mock_self)
@ -380,4 +358,4 @@ class ActivitypubMixins(TestCase):
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Undo')
self.assertEqual(activity['object'], {})
self.assertIsInstance(activity['object'], dict)

View file

@ -17,6 +17,7 @@ from django.db import models
from django.test import TestCase
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.models import fields, User, Status
from bookwyrm.models.base_model import BookWyrmModel
@ -275,7 +276,7 @@ class ActivitypubFields(TestCase):
'rat', 'rat@rat.rat', 'ratword',
local=True, localname='rat')
with patch('bookwyrm.models.user.set_remote_server.delay'):
value = instance.field_from_activity(userdata)
value = instance.field_from_activity(activitypub.Person(**userdata))
self.assertIsInstance(value, User)
self.assertNotEqual(value, unrelated_user)
self.assertEqual(value.remote_id, 'https://example.com/user/mouse')
@ -300,7 +301,7 @@ class ActivitypubFields(TestCase):
local=True, localname='rat')
with patch('bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast'):
value = instance.field_from_activity(userdata)
value = instance.field_from_activity(activitypub.Person(**userdata))
self.assertEqual(value, user)

View file

@ -171,6 +171,8 @@ class ImportJob(TestCase):
'bookwyrm.connectors.connector_manager.first_search_result'
) as search:
search.return_value = result
book = self.item_1.get_book_from_isbn()
with patch('bookwyrm.connectors.openlibrary.Connector.' \
'get_authors_from_data'):
book = self.item_1.get_book_from_isbn()
self.assertEqual(book.title, 'Sabriel')

View file

@ -23,19 +23,6 @@ class Relationship(TestCase):
self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save(broadcast=False)
def test_user_follows(self):
''' create a follow relationship '''
with patch('bookwyrm.models.activitypub_mixin.ActivityMixin.broadcast'):
rel = models.UserFollows.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
activity = rel.to_activity()
self.assertEqual(activity['id'], rel.remote_id)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object'], self.remote_user.remote_id)
def test_user_follows_from_request(self):
''' convert a follow request into a follow '''
@ -116,13 +103,15 @@ class Relationship(TestCase):
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Accept')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(
activity['object']['id'], request.remote_id)
self.assertEqual(activity['object']['id'], 'https://www.hi.com/')
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
models.UserFollowRequest.broadcast = mock_broadcast
request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user,
remote_id='https://www.hi.com/'
)
request.accept()
@ -145,6 +134,8 @@ class Relationship(TestCase):
activity['object']['id'], request.remote_id)
models.UserFollowRequest.broadcast = mock_reject
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user,

View file

@ -4,6 +4,7 @@ from django.test import TestCase
from bookwyrm import models, settings
#pylint: disable=unused-argument
class Shelf(TestCase):
''' some activitypub oddness ahead '''
def setUp(self):

View file

@ -63,7 +63,7 @@ class Status(TestCase):
self.assertEqual(models.Review().status_type, 'Review')
self.assertEqual(models.Quotation().status_type, 'Quotation')
self.assertEqual(models.Comment().status_type, 'Comment')
self.assertEqual(models.Boost().status_type, 'Boost')
self.assertEqual(models.Boost().status_type, 'Announce')
def test_boostable(self, _):
''' can a status be boosted, based on privacy '''
@ -284,3 +284,24 @@ class Status(TestCase):
with self.assertRaises(IntegrityError):
models.Notification.objects.create(
user=self.user, notification_type='GLORB')
def test_create_broadcast(self, broadcast_mock):
''' should send out two verions of a status on create '''
models.Comment.objects.create(
content='hi', user=self.user, book=self.book)
self.assertEqual(broadcast_mock.call_count, 2)
pure_call = broadcast_mock.call_args_list[0]
bw_call = broadcast_mock.call_args_list[1]
self.assertEqual(pure_call[1]['software'], 'other')
args = pure_call[0][0]
self.assertEqual(args['type'], 'Create')
self.assertEqual(args['object']['type'], 'Note')
self.assertTrue('content' in args['object'])
self.assertEqual(bw_call[1]['software'], 'bookwyrm')
args = bw_call[0][0]
self.assertEqual(args['type'], 'Create')
self.assertEqual(args['object']['type'], 'Comment')

View file

@ -1,3 +1,4 @@
''' getting and verifying signatures '''
import time
from collections import namedtuple
from urllib.parse import urlsplit
@ -12,31 +13,33 @@ import pytest
from django.test import TestCase, Client
from django.utils.http import http_date
from bookwyrm.models import User
from bookwyrm import models
from bookwyrm.activitypub import Follow
from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair, make_signature, make_digest
def get_follow_data(follower, followee):
follow_activity = Follow(
def get_follow_activity(follower, followee):
''' generates a test activity '''
return Follow(
id='https://test.com/user/follow/id',
actor=follower.remote_id,
object=followee.remote_id,
).serialize()
return json.dumps(follow_activity)
KeyPair = namedtuple('KeyPair', ('private_key', 'public_key'))
Sender = namedtuple('Sender', ('remote_id', 'key_pair'))
class Signature(TestCase):
''' signature test '''
def setUp(self):
self.mouse = User.objects.create_user(
''' create users and test data '''
self.mouse = models.User.objects.create_user(
'mouse@%s' % DOMAIN, 'mouse@example.com', '',
local=True, localname='mouse')
self.rat = User.objects.create_user(
self.rat = models.User.objects.create_user(
'rat@%s' % DOMAIN, 'rat@example.com', '',
local=True, localname='rat')
self.cat = User.objects.create_user(
self.cat = models.User.objects.create_user(
'cat@%s' % DOMAIN, 'cat@example.com', '',
local=True, localname='cat')
@ -47,6 +50,8 @@ class Signature(TestCase):
KeyPair(private_key, public_key)
)
models.SiteSettings.objects.create()
def send(self, signature, now, data, digest):
''' test request '''
c = Client()
@ -63,7 +68,7 @@ class Signature(TestCase):
}
)
def send_test_request(
def send_test_request(#pylint: disable=too-many-arguments
self,
sender,
signer=None,
@ -72,15 +77,16 @@ class Signature(TestCase):
date=None):
''' sends a follow request to the "rat" user '''
now = date or http_date()
data = json.dumps(get_follow_data(sender, self.rat))
data = json.dumps(get_follow_activity(sender, self.rat))
digest = digest or make_digest(data)
signature = make_signature(
signer or sender, self.rat.inbox, now, digest)
with patch('bookwyrm.incoming.handle_follow.delay'):
with patch('bookwyrm.views.inbox.activity_task.delay'):
with patch('bookwyrm.models.user.set_remote_server.delay'):
return self.send(signature, now, send_data or data, digest)
def test_correct_signature(self):
''' this one should just work '''
response = self.send_test_request(sender=self.mouse)
self.assertEqual(response.status_code, 200)
@ -120,6 +126,7 @@ class Signature(TestCase):
@responses.activate
def test_key_needs_refresh(self):
''' an out of date key should be updated and the new key work '''
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json')
data = json.loads(datafile.read_bytes())
data['id'] = self.fake_remote.remote_id
@ -165,6 +172,7 @@ class Signature(TestCase):
@responses.activate
def test_nonexistent_signer(self):
''' fail when unable to look up signer '''
responses.add(
responses.GET,
self.fake_remote.remote_id,
@ -180,11 +188,12 @@ class Signature(TestCase):
with patch('bookwyrm.activitypub.resolve_remote_id'):
response = self.send_test_request(
self.mouse,
send_data=get_follow_data(self.mouse, self.cat))
send_data=get_follow_activity(self.mouse, self.cat))
self.assertEqual(response.status_code, 401)
@pytest.mark.integration
def test_invalid_digest(self):
''' signature digest must be valid '''
with patch('bookwyrm.activitypub.resolve_remote_id'):
response = self.send_test_request(
self.mouse,

View file

@ -77,7 +77,6 @@ class BookViews(TestCase):
self.assertEqual(rel.status, 'follow_request')
def test_handle_follow_local(self):
''' send a follow request '''
rat = models.User.objects.create_user(
@ -105,14 +104,18 @@ class BookViews(TestCase):
request.user = self.local_user
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
as mock:
views.unfollow(request)
self.assertEqual(mock.call_count, 1)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
''' accept a follow request '''
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
rel = models.UserFollowRequest.objects.create(
@ -132,6 +135,8 @@ class BookViews(TestCase):
def test_handle_reject(self):
''' reject a follow request '''
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
rel = models.UserFollowRequest.objects.create(

View file

@ -1,23 +1,22 @@
''' test incoming activities '''
''' tests incoming activities'''
from datetime import datetime
import json
import pathlib
from unittest.mock import patch
from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \
HttpResponseNotFound
from django.test import TestCase
from django.test.client import RequestFactory
from django.http import HttpResponseNotAllowed, HttpResponseNotFound
from django.test import TestCase, Client
import responses
from bookwyrm import models, incoming
from bookwyrm import models, views
#pylint: disable=too-many-public-methods
class Incoming(TestCase):
''' a lot here: all handlers for receiving activitypub requests '''
class Inbox(TestCase):
''' readthrough tests '''
def setUp(self):
''' we need basic things, like users '''
''' basic user and book data '''
self.client = Client()
self.local_user = models.User.objects.create_user(
'mouse@example.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
@ -37,78 +36,207 @@ class Incoming(TestCase):
content='Test status',
remote_id='https://example.com/status/1',
)
self.factory = RequestFactory()
self.create_json = {
'id': 'hi',
'type': 'Create',
'actor': 'hi',
"to": [
"https://www.w3.org/ns/activitystreams#public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
'object': {}
}
models.SiteSettings.objects.create()
def test_inbox_invalid_get(self):
''' shouldn't try to handle if the user is not found '''
request = self.factory.get('https://www.example.com/')
self.assertIsInstance(
incoming.inbox(request, 'anything'), HttpResponseNotAllowed)
self.assertIsInstance(
incoming.shared_inbox(request), HttpResponseNotAllowed)
result = self.client.get(
'/inbox', content_type="application/json"
)
self.assertIsInstance(result, HttpResponseNotAllowed)
def test_inbox_invalid_user(self):
''' shouldn't try to handle if the user is not found '''
request = self.factory.post('https://www.example.com/')
self.assertIsInstance(
incoming.inbox(request, 'fish@tomato.com'), HttpResponseNotFound)
def test_inbox_invalid_no_object(self):
''' json is missing "object" field '''
request = self.factory.post(
self.local_user.shared_inbox, data={})
self.assertIsInstance(
incoming.shared_inbox(request), HttpResponseBadRequest)
result = self.client.post(
'/user/bleh/inbox',
'{"type": "Test", "object": "exists"}',
content_type="application/json"
)
self.assertIsInstance(result, HttpResponseNotFound)
def test_inbox_invalid_bad_signature(self):
''' bad request for invalid signature '''
request = self.factory.post(
self.local_user.shared_inbox,
'{"type": "Test", "object": "exists"}',
content_type='application/json')
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
mock_has_valid.return_value = False
self.assertEqual(
incoming.shared_inbox(request).status_code, 401)
with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid:
mock_valid.return_value = False
result = self.client.post(
'/user/mouse/inbox',
'{"type": "Test", "object": "exists"}',
content_type="application/json"
)
self.assertEqual(result.status_code, 401)
def test_inbox_invalid_bad_signature_delete(self):
''' invalid signature for Delete is okay though '''
request = self.factory.post(
self.local_user.shared_inbox,
'{"type": "Delete", "object": "exists"}',
content_type='application/json')
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
mock_has_valid.return_value = False
self.assertEqual(
incoming.shared_inbox(request).status_code, 200)
with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid:
mock_valid.return_value = False
result = self.client.post(
'/user/mouse/inbox',
'{"type": "Delete", "object": "exists"}',
content_type="application/json"
)
self.assertEqual(result.status_code, 200)
def test_inbox_unknown_type(self):
''' never heard of that activity type, don't have a handler for it '''
request = self.factory.post(
self.local_user.shared_inbox,
'{"type": "Fish", "object": "exists"}',
content_type='application/json')
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
mock_has_valid.return_value = True
self.assertIsInstance(
incoming.shared_inbox(request), HttpResponseNotFound)
with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid:
result = self.client.post(
'/inbox',
'{"type": "Fish", "object": "exists"}',
content_type="application/json"
)
mock_valid.return_value = True
self.assertIsInstance(result, HttpResponseNotFound)
def test_inbox_success(self):
''' a known type, for which we start a task '''
request = self.factory.post(
self.local_user.shared_inbox,
'{"type": "Accept", "object": "exists"}',
content_type='application/json')
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
mock_has_valid.return_value = True
activity = self.create_json
activity['object'] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid:
mock_valid.return_value = True
with patch('bookwyrm.incoming.handle_follow_accept.delay'):
self.assertEqual(
incoming.shared_inbox(request).status_code, 200)
with patch('bookwyrm.views.inbox.activity_task.delay'):
result = self.client.post(
'/inbox',
json.dumps(activity),
content_type="application/json"
)
self.assertEqual(result.status_code, 200)
def test_handle_follow(self):
def test_handle_create_status(self):
''' the "it justs works" mode '''
self.assertEqual(models.Status.objects.count(), 1)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_quotation.json')
status_data = json.loads(datafile.read_bytes())
models.Edition.objects.create(
title='Test Book', remote_id='https://example.com/book/1')
activity = self.create_json
activity['object'] = status_data
views.inbox.activity_task(activity)
status = models.Quotation.objects.get()
self.assertEqual(
status.remote_id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(status.quote, 'quote body')
self.assertEqual(status.content, 'commentary')
self.assertEqual(status.user, self.local_user)
self.assertEqual(models.Status.objects.count(), 2)
# while we're here, lets ensure we avoid dupes
views.inbox.activity_task(activity)
self.assertEqual(models.Status.objects.count(), 2)
def test_handle_create_status_remote_note_with_mention(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
models.Notification.objects.filter(user=self.local_user).exists())
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_note.json')
status_data = json.loads(datafile.read_bytes())
activity = self.create_json
activity['object'] = status_data
views.inbox.activity_task(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.mention_users.first(), self.local_user)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user).exists())
self.assertEqual(
models.Notification.objects.get().notification_type, 'MENTION')
def test_handle_create_status_remote_note_with_reply(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
models.Notification.objects.filter(user=self.local_user))
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_note.json')
status_data = json.loads(datafile.read_bytes())
del status_data['tag']
status_data['inReplyTo'] = self.status.remote_id
activity = self.create_json
activity['object'] = status_data
views.inbox.activity_task(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.reply_parent, self.status)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user))
self.assertEqual(
models.Notification.objects.get().notification_type, 'REPLY')
def test_handle_create_list(self):
''' a new list '''
activity = self.create_json
activity['object'] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
views.inbox.activity_task(activity)
book_list = models.List.objects.get()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
self.assertEqual(book_list.description, 'summary text')
self.assertEqual(book_list.remote_id, 'https://example.com/list/22')
def test_handle_follow_x(self):
''' remote user wants to follow local user '''
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
@ -118,8 +246,11 @@ class Incoming(TestCase):
"object": "https://example.com/user/mouse"
}
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
incoming.handle_follow(activity)
self.assertFalse(models.UserFollowRequest.objects.exists())
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
as mock:
views.inbox.activity_task(activity)
self.assertEqual(mock.call_count, 1)
# notification created
notification = models.Notification.objects.get()
@ -127,8 +258,7 @@ class Incoming(TestCase):
self.assertEqual(notification.notification_type, 'FOLLOW')
# the request should have been deleted
requests = models.UserFollowRequest.objects.all()
self.assertEqual(list(requests), [])
self.assertFalse(models.UserFollowRequest.objects.exists())
# the follow relationship should exist
follow = models.UserFollows.objects.get(user_object=self.local_user)
@ -149,7 +279,7 @@ class Incoming(TestCase):
self.local_user.save(broadcast=False)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
incoming.handle_follow(activity)
views.inbox.activity_task(activity)
# notification created
notification = models.Notification.objects.get()
@ -168,48 +298,52 @@ class Incoming(TestCase):
def test_handle_unfollow(self):
''' remove a relationship '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
rel = models.UserFollows.objects.create(
user_subject=self.remote_user, user_object=self.local_user)
activity = {
"type": "Undo",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
'actor': self.remote_user.remote_id,
"@context": "https://www.w3.org/ns/activitystreams",
"object": {
"id": "https://example.com/users/rat/follows/123",
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse"
}
}
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollows.objects.create(
user_subject=self.remote_user, user_object=self.local_user)
self.assertEqual(self.remote_user, self.local_user.followers.first())
incoming.handle_unfollow(activity)
views.inbox.activity_task(activity)
self.assertIsNone(self.local_user.followers.first())
def test_handle_follow_accept(self):
''' a remote user approved a follow request from local '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/rat/follows/123#accepts",
"type": "Accept",
"actor": "https://example.com/users/rat",
"object": {
"id": "https://example.com/users/rat/follows/123",
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/user/mouse",
"object": "https://example.com/users/rat"
}
}
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
incoming.handle_follow_accept(activity)
views.inbox.activity_task(activity)
# request should be deleted
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
@ -222,64 +356,31 @@ class Incoming(TestCase):
def test_handle_follow_reject(self):
''' turn down a follow request '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/rat/follows/123#accepts",
"type": "Reject",
"actor": "https://example.com/users/rat",
"object": {
"id": "https://example.com/users/rat/follows/123",
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/user/mouse",
"object": "https://example.com/users/rat"
}
}
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
incoming.handle_follow_reject(activity)
views.inbox.activity_task(activity)
# request should be deleted
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
# relationship should be created
follows = self.remote_user.followers
self.assertEqual(follows.count(), 0)
def test_handle_create_list(self):
''' a new list '''
activity = {
'object': {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
}
incoming.handle_create_list(activity)
book_list = models.List.objects.get()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
self.assertEqual(book_list.description, 'summary text')
self.assertEqual(book_list.remote_id, 'https://example.com/list/22')
self.assertFalse(models.UserFollowRequest.objects.exists())
self.assertFalse(self.remote_user.followers.exists())
def test_handle_update_list(self):
@ -289,6 +390,9 @@ class Incoming(TestCase):
name='hi', remote_id='https://example.com/list/22',
user=self.local_user)
activity = {
'type': 'Update',
'to': [], 'cc': [], 'actor': 'hi',
'id': 'sdkjf',
'object': {
"id": "https://example.com/list/22",
"type": "BookList",
@ -308,7 +412,7 @@ class Incoming(TestCase):
"@context": "https://www.w3.org/ns/activitystreams"
}
}
incoming.handle_update_list(activity)
views.inbox.activity_task(activity)
book_list.refresh_from_db()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
@ -316,80 +420,6 @@ class Incoming(TestCase):
self.assertEqual(book_list.remote_id, 'https://example.com/list/22')
def test_handle_create_status(self):
''' the "it justs works" mode '''
self.assertEqual(models.Status.objects.count(), 1)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_quotation.json')
status_data = json.loads(datafile.read_bytes())
models.Edition.objects.create(
title='Test Book', remote_id='https://example.com/book/1')
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create_status(activity)
status = models.Quotation.objects.get()
self.assertEqual(
status.remote_id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(status.quote, 'quote body')
self.assertEqual(status.content, 'commentary')
self.assertEqual(status.user, self.local_user)
self.assertEqual(models.Status.objects.count(), 2)
# while we're here, lets ensure we avoid dupes
incoming.handle_create_status(activity)
self.assertEqual(models.Status.objects.count(), 2)
def test_handle_create_status_unknown_type(self):
''' folks send you all kinds of things '''
activity = {'object': {'id': 'hi'}, 'type': 'Fish'}
result = incoming.handle_create_status(activity)
self.assertIsNone(result)
def test_handle_create_status_remote_note_with_mention(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
models.Notification.objects.filter(user=self.local_user).exists())
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_note.json')
status_data = json.loads(datafile.read_bytes())
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create_status(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.mention_users.first(), self.local_user)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user).exists())
self.assertEqual(
models.Notification.objects.get().notification_type, 'MENTION')
def test_handle_create_status_remote_note_with_reply(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
models.Notification.objects.filter(user=self.local_user))
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_note.json')
status_data = json.loads(datafile.read_bytes())
del status_data['tag']
status_data['inReplyTo'] = self.status.remote_id
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create_status(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.reply_parent, self.status)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user))
self.assertEqual(
models.Notification.objects.get().notification_type, 'REPLY')
def test_handle_delete_status(self):
''' remove a status '''
self.status.user = self.remote_user
@ -398,11 +428,13 @@ class Incoming(TestCase):
self.assertFalse(self.status.deleted)
activity = {
'type': 'Delete',
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
'id': '%s/activity' % self.status.remote_id,
'actor': self.remote_user.remote_id,
'object': {'id': self.status.remote_id},
'object': {'id': self.status.remote_id, 'type': 'Tombstone'},
}
incoming.handle_delete_status(activity)
views.inbox.activity_task(activity)
# deletion doens't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
@ -427,11 +459,13 @@ class Incoming(TestCase):
self.assertEqual(models.Notification.objects.count(), 2)
activity = {
'type': 'Delete',
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
'id': '%s/activity' % self.status.remote_id,
'actor': self.remote_user.remote_id,
'object': {'id': self.status.remote_id},
'object': {'id': self.status.remote_id, 'type': 'Tombstone'},
}
incoming.handle_delete_status(activity)
views.inbox.activity_task(activity)
# deletion doens't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
@ -448,11 +482,12 @@ class Incoming(TestCase):
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://example.com/fav/1',
'actor': 'https://example.com/users/rat',
'type': 'Like',
'published': 'Mon, 25 May 2020 19:31:20 GMT',
'object': 'https://example.com/status/1',
}
incoming.handle_favorite(activity)
views.inbox.activity_task(activity)
fav = models.Favorite.objects.get(remote_id='https://example.com/fav/1')
self.assertEqual(fav.status, self.status)
@ -464,12 +499,16 @@ class Incoming(TestCase):
activity = {
'id': 'https://example.com/fav/1#undo',
'type': 'Undo',
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
'actor': self.remote_user.remote_id,
'object': {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://example.com/fav/1',
'actor': 'https://example.com/users/rat',
'type': 'Like',
'published': 'Mon, 25 May 2020 19:31:20 GMT',
'object': 'https://example.com/fav/1',
'object': self.status.remote_id,
}
}
models.Favorite.objects.create(
@ -478,7 +517,7 @@ class Incoming(TestCase):
remote_id='https://example.com/fav/1')
self.assertEqual(models.Favorite.objects.count(), 1)
incoming.handle_unfavorite(activity)
views.inbox.activity_task(activity)
self.assertEqual(models.Favorite.objects.count(), 0)
@ -489,12 +528,12 @@ class Incoming(TestCase):
'type': 'Announce',
'id': '%s/boost' % self.status.remote_id,
'actor': self.remote_user.remote_id,
'object': self.status.to_activity(),
'object': self.status.remote_id,
}
with patch('bookwyrm.models.status.Status.ignore_activity') \
as discarder:
discarder.return_value = False
incoming.handle_boost(activity)
views.inbox.activity_task(activity)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, self.status)
notification = models.Notification.objects.get()
@ -505,41 +544,52 @@ class Incoming(TestCase):
@responses.activate
def test_handle_discarded_boost(self):
''' test a boost of a mastodon status that will be discarded '''
status = models.Status(
content='hi',
user=self.remote_user,
)
status.save(broadcast=False)
activity = {
'type': 'Announce',
'id': 'http://www.faraway.com/boost/12',
'actor': self.remote_user.remote_id,
'object': self.status.to_activity(),
'object': status.remote_id,
}
responses.add(
responses.GET,
'http://www.faraway.com/boost/12',
json={'id': 'http://www.faraway.com/boost/12'},
status.remote_id,
json=status.to_activity(),
status=200)
incoming.handle_boost(activity)
views.inbox.activity_task(activity)
self.assertEqual(models.Boost.objects.count(), 0)
def test_handle_unboost(self):
''' undo a boost '''
boost = models.Boost.objects.create(
boosted_status=self.status, user=self.remote_user)
activity = {
'type': 'Undo',
'actor': 'hi',
'id': 'bleh',
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
'object': {
'type': 'Announce',
'id': '%s/boost' % self.status.remote_id,
'actor': self.local_user.remote_id,
'object': self.status.to_activity(),
'id': boost.remote_id,
'actor': self.remote_user.remote_id,
'object': self.status.remote_id,
}
}
models.Boost.objects.create(
boosted_status=self.status, user=self.remote_user)
incoming.handle_unboost(activity)
views.inbox.activity_task(activity)
def test_handle_add_book(self):
def test_handle_add_book_to_shelf(self):
''' shelving a book '''
work = models.Work.objects.create(title='work title')
book = models.Edition.objects.create(
title='Test', remote_id='https://bookwyrm.social/book/37292')
title='Test', remote_id='https://bookwyrm.social/book/37292',
parent_work=work)
shelf = models.Shelf.objects.create(
user=self.remote_user, name='Test Shelf')
shelf.remote_id = 'https://bookwyrm.social/user/mouse/shelf/to-read'
@ -549,14 +599,113 @@ class Incoming(TestCase):
"id": "https://bookwyrm.social/shelfbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": "https://bookwyrm.social/book/37292",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams"
}
incoming.handle_add(activity)
views.inbox.activity_task(activity)
self.assertEqual(shelf.books.first(), book)
@responses.activate
def test_handle_add_book_to_list(self):
''' listing a book '''
work = models.Work.objects.create(title='work title')
book = models.Edition.objects.create(
title='Test', remote_id='https://bookwyrm.social/book/37292',
parent_work=work)
responses.add(
responses.GET,
'https://bookwyrm.social/user/mouse/list/to-read',
json={
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/list/to-read",
"@context": "https://www.w3.org/ns/activitystreams"
}
views.inbox.activity_task(activity)
booklist = models.List.objects.get()
self.assertEqual(booklist.name, 'Test List')
self.assertEqual(booklist.books.first(), book)
@responses.activate
def test_handle_tag_book(self):
''' listing a book '''
work = models.Work.objects.create(title='work title')
book = models.Edition.objects.create(
title='Test', remote_id='https://bookwyrm.social/book/37292',
parent_work=work)
responses.add(
responses.GET,
'https://www.example.com/tag/cool-tag',
json={
"id": "https://1b1a78582461.ngrok.io/tag/tag",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"last": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"name": "cool tag",
"@context": "https://www.w3.org/ns/activitystreams"
}
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://www.example.com/tag/cool-tag",
"@context": "https://www.w3.org/ns/activitystreams"
}
views.inbox.activity_task(activity)
tag = models.Tag.objects.get()
self.assertFalse(models.List.objects.exists())
self.assertEqual(tag.name, 'cool tag')
self.assertEqual(tag.books.first(), book)
def test_handle_update_user(self):
''' update an existing user '''
# we only do this with remote users
@ -564,11 +713,16 @@ class Incoming(TestCase):
self.local_user.save()
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json')
'../data/ap_user.json')
userdata = json.loads(datafile.read_bytes())
del userdata['icon']
self.assertIsNone(self.local_user.name)
incoming.handle_update_user({'object': userdata})
views.inbox.activity_task({
'type': 'Update',
'to': [], 'cc': [], 'actor': 'hi',
'id': 'sdkjf',
'object': userdata
})
user = models.User.objects.get(id=self.local_user.id)
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
self.assertEqual(user.username, 'mouse@example.com')
@ -578,7 +732,7 @@ class Incoming(TestCase):
def test_handle_update_edition(self):
''' update an existing edition '''
datafile = pathlib.Path(__file__).parent.joinpath(
'data/bw_edition.json')
'../data/bw_edition.json')
bookdata = json.loads(datafile.read_bytes())
models.Work.objects.create(
@ -591,7 +745,12 @@ class Incoming(TestCase):
with patch(
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
incoming.handle_update_edition({'object': bookdata})
views.inbox.activity_task({
'type': 'Update',
'to': [], 'cc': [], 'actor': 'hi',
'id': 'sdkjf',
'object': bookdata
})
book = models.Edition.objects.get(id=book.id)
self.assertEqual(book.title, 'Piranesi')
@ -599,7 +758,7 @@ class Incoming(TestCase):
def test_handle_update_work(self):
''' update an existing edition '''
datafile = pathlib.Path(__file__).parent.joinpath(
'data/bw_work.json')
'../data/bw_work.json')
bookdata = json.loads(datafile.read_bytes())
book = models.Work.objects.create(
@ -609,7 +768,12 @@ class Incoming(TestCase):
self.assertEqual(book.title, 'Test Book')
with patch(
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
incoming.handle_update_work({'object': bookdata})
views.inbox.activity_task({
'type': 'Update',
'to': [], 'cc': [], 'actor': 'hi',
'id': 'sdkjf',
'object': bookdata
})
book = models.Work.objects.get(id=book.id)
self.assertEqual(book.title, 'Piranesi')
@ -632,7 +796,7 @@ class Incoming(TestCase):
"object": "https://example.com/user/mouse"
}
incoming.handle_block(activity)
views.inbox.activity_task(activity)
block = models.UserBlocks.objects.get()
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
@ -653,12 +817,18 @@ class Incoming(TestCase):
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
activity = {'type': 'Undo', 'object': {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse"
}}
incoming.handle_unblock(activity)
activity = {
'type': 'Undo',
'actor': 'hi',
'id': 'bleh',
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
'object': {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse"
}}
views.inbox.activity_task(activity)
self.assertFalse(models.UserBlocks.objects.exists())

View file

@ -138,7 +138,7 @@ class InteractionViews(TestCase):
''' undo a boost '''
view = views.Unboost.as_view()
request = self.factory.post('')
request.user = self.remote_user
request.user = self.local_user
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
@ -146,7 +146,9 @@ class InteractionViews(TestCase):
self.assertEqual(models.Boost.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
as mock:
view(request, status.id)
self.assertEqual(mock.call_count, 1)
self.assertEqual(models.Boost.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)

View file

@ -9,7 +9,7 @@ from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
#pylint: disable=unused-argument
class ListViews(TestCase):
''' tag views'''
def setUp(self):
@ -45,7 +45,7 @@ class ListViews(TestCase):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
name='Private list', privacy='direct', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
@ -164,7 +164,7 @@ class ListViews(TestCase):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
name='Private list', privacy='direct', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user

View file

@ -1,4 +1,5 @@
''' test for app action functionality '''
import json
from unittest.mock import patch
from django.test import TestCase
from django.test.client import RequestFactory
@ -236,7 +237,11 @@ class StatusViews(TestCase):
self.assertFalse(status.deleted)
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
as mock:
view(request, status.id)
activity = json.loads(mock.call_args_list[0][0][1])
self.assertEqual(activity['type'], 'Delete')
self.assertEqual(activity['object']['type'], 'Tombstone')
status.refresh_from_db()
self.assertTrue(status.deleted)

View file

@ -59,6 +59,21 @@ class TagViews(TestCase):
self.assertEqual(result.status_code, 200)
def test_tag_page_activitypub_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Tag.as_view()
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
request = self.factory.get('', {'page': 1})
with patch('bookwyrm.views.tag.is_api_request') as is_api:
is_api.return_value = True
result = view(request, tag.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_tag(self):
''' add a tag to a book '''
view = views.AddTag.as_view()

View file

@ -4,7 +4,7 @@ from django.contrib import admin
from django.urls import path, re_path
from bookwyrm import incoming, settings, views, wellknown
from bookwyrm import settings, views, wellknown
from bookwyrm.utils import regex
user_path = r'^user/(?P<username>%s)' % regex.username
@ -29,8 +29,8 @@ urlpatterns = [
path('admin/', admin.site.urls),
# federation endpoints
re_path(r'^inbox/?$', incoming.shared_inbox),
re_path(r'%s/inbox/?$' % local_user_path, incoming.inbox),
re_path(r'^inbox/?$', views.Inbox.as_view()),
re_path(r'%s/inbox/?$' % local_user_path, views.Inbox.as_view()),
re_path(r'%s/outbox/?$' % local_user_path, views.Outbox.as_view()),
re_path(r'^.well-known/webfinger/?$', wellknown.webfinger),
re_path(r'^.well-known/nodeinfo/?$', wellknown.nodeinfo_pointer),

View file

@ -11,6 +11,7 @@ from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request
from .goal import Goal
from .import_data import Import, ImportStatus
from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .invite import ManageInvites, Invite
from .landing import About, Home, Discover

View file

@ -1,5 +1,6 @@
''' views for actions you can take in the application '''
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.views.decorators.http import require_POST
@ -17,13 +18,14 @@ def follow(request):
except models.User.DoesNotExist:
return HttpResponseBadRequest()
rel, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=request.user,
user_object=to_follow,
)
try:
models.UserFollowRequest.objects.create(
user_subject=request.user,
user_object=to_follow,
)
except IntegrityError:
pass
if to_follow.local and not to_follow.manually_approves_followers:
rel.accept()
return redirect(to_follow.local_path)
@ -40,9 +42,7 @@ def unfollow(request):
models.UserFollows.objects.get(
user_subject=request.user,
user_object=to_unfollow
)
to_unfollow.followers.remove(request.user)
).delete()
return redirect(to_unfollow.local_path)

View file

@ -192,7 +192,7 @@ def handle_remote_webfinger(query):
if link.get('rel') == 'self':
try:
user = activitypub.resolve_remote_id(
models.User, link['href']
link['href'], model=models.User
)
except KeyError:
return None

95
bookwyrm/views/inbox.py Normal file
View file

@ -0,0 +1,95 @@
''' incoming activities '''
import json
from urllib.parse import urldefrag
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from bookwyrm import activitypub, models
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@method_decorator(csrf_exempt, name='dispatch')
# pylint: disable=no-self-use
class Inbox(View):
''' requests sent by outside servers'''
def post(self, request, username=None):
''' only works as POST request '''
# first let's do some basic checks to see if this is legible
if username:
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
# is it valid json? does it at least vaguely resemble an activity?
try:
activity_json = json.loads(request.body)
except json.decoder.JSONDecodeError:
return HttpResponseBadRequest()
# verify the signature
if not has_valid_signature(request, activity_json):
if activity_json['type'] == 'Delete':
# Pretend that unauth'd deletes succeed. Auth may be failing
# because the resource or owner of the resource might have
# been deleted.
return HttpResponse()
return HttpResponse(status=401)
# just some quick smell tests before we try to parse the json
if not 'object' in activity_json or \
not 'type' in activity_json or \
not activity_json['type'] in activitypub.activity_objects:
return HttpResponseNotFound()
activity_task.delay(activity_json)
return HttpResponse()
@app.task
def activity_task(activity_json):
''' do something with this json we think is legit '''
# lets see if the activitypub module can make sense of this json
try:
activity = activitypub.parse(activity_json)
except activitypub.ActivitySerializerError:
return
# cool that worked, now we should do the action described by the type
# (create, update, delete, etc)
activity.action()
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
key_actor = urldefrag(signature.key_id).url
if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.")
remote_user = activitypub.resolve_remote_id(
key_actor, model=models.User)
if not remote_user:
return False
try:
signature.verify(remote_user.key_pair.public_key, request)
except ValueError:
old_key = remote_user.key_pair.public_key
remote_user = activitypub.resolve_remote_id(
remote_user.remote_id, model=models.User, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True

View file

@ -1,6 +1,5 @@
''' tagging views'''
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
@ -16,12 +15,11 @@ class Tag(View):
''' tag page '''
def get(self, request, tag_id):
''' see books related to a tag '''
tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
if not tag_obj:
return HttpResponseNotFound()
tag_obj = get_object_or_404(models.Tag, identifier=tag_id)
if is_api_request(request):
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
return ActivitypubResponse(
tag_obj.to_activity(**request.GET))
books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id

View file

@ -25,5 +25,5 @@ app.autodiscover_tasks(
['bookwyrm'], related_name='connectors.abstract_connector')
app.autodiscover_tasks(['bookwyrm'], related_name='emailing')
app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import')
app.autodiscover_tasks(['bookwyrm'], related_name='incoming')
app.autodiscover_tasks(['bookwyrm'], related_name='models.user')
app.autodiscover_tasks(['bookwyrm'], related_name='views.inbox')