mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-30 03:50:40 +00:00
commit
0ecfff0f16
43 changed files with 958 additions and 887 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -67,4 +67,4 @@ class Author(ActivityObject):
|
|||
librarythingKey: str = ''
|
||||
goodreadsKey: str = ''
|
||||
wikipediaLink: str = ''
|
||||
type: str = 'Person'
|
||||
type: str = 'Author'
|
||||
|
|
|
@ -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'
|
|
@ -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):
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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 '''
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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())
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
95
bookwyrm/views/inbox.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in a new issue