mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-04 23:36:32 +00:00
Merge branch 'main' into production
This commit is contained in:
commit
489ac29761
51 changed files with 1087 additions and 952 deletions
13
README.md
13
README.md
|
@ -26,16 +26,16 @@ There are many ways you can contribute to this project, regardless of your level
|
|||
Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
|
||||
|
||||
### Code contributions
|
||||
Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
|
||||
Code contributions are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
|
||||
|
||||
If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
|
||||
If you have questions about the project or contributing, you can set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
|
||||
|
||||
### Financial Support
|
||||
BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
|
||||
|
||||
## About BookWyrm
|
||||
### What it is and isn't
|
||||
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
|
||||
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
|
||||
|
||||
### The role of federation
|
||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||
|
@ -111,17 +111,17 @@ Once the build is complete, you can access the instance at `localhost:1333`
|
|||
|
||||
## Installing in Production
|
||||
|
||||
This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
|
||||
This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production.
|
||||
|
||||
### Server setup
|
||||
- Get a domain name and set up DNS for your server
|
||||
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
|
||||
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04)
|
||||
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
|
||||
- Install Docker and docker-compose
|
||||
|
||||
### Install and configure BookWyrm
|
||||
|
||||
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and indivudal changes to container config to enable things like SSL or regular backups.
|
||||
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups.
|
||||
|
||||
Instructions for running BookWyrm in production:
|
||||
|
||||
|
@ -171,3 +171,4 @@ There are three concepts in the book data model:
|
|||
- `Edition`, a concrete, actually published version of a book
|
||||
|
||||
Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page.
|
||||
|
||||
|
|
|
@ -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:
|
||||
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,8 +227,21 @@ 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 '''
|
||||
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
|
||||
|
@ -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
|
||||
|
||||
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)
|
18
bookwyrm/migrations/0046_sitesettings_privacy_policy.py
Normal file
18
bookwyrm/migrations/0046_sitesettings_privacy_policy.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-27 19:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0045_auto_20210210_2114'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='privacy_policy',
|
||||
field=models.TextField(default='Add a privacy policy here.'),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
@ -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
|
||||
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
|
||||
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'
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ class SiteSettings(models.Model):
|
|||
default='Contact an administrator to get an invite')
|
||||
code_of_conduct = models.TextField(
|
||||
default='Add a code of conduct here.')
|
||||
privacy_policy = models.TextField(
|
||||
default='Add a privacy policy here.')
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
logo = models.ImageField(
|
||||
upload_to='logos/', null=True, blank=True
|
||||
|
|
|
@ -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']
|
||||
for tag in tags:
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
for tag in tags:
|
||||
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()
|
||||
|
|
|
@ -1,22 +1,36 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
{% extends 'discover/landing_layout.html' %}
|
||||
{% block panel %}
|
||||
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
<div class="block columns mt-4">
|
||||
<nav class="menu column is-one-quarter">
|
||||
<h2 class="menu-label">About {{ site.name }}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="#coc">Code of Conduct</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#privacy">Privacy Policy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{% include 'discover/icons.html' %}
|
||||
|
||||
<section class="block">
|
||||
{% include 'snippets/about.html' %}
|
||||
</section>
|
||||
|
||||
<div class="block">
|
||||
<div class="column content">
|
||||
<div class="block" id="coc">
|
||||
<h2 class="title">Code of Conduct</h2>
|
||||
<div class="content">
|
||||
{{ site.code_of_conduct | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
||||
<div class="block" id="privacy">
|
||||
<h2 class="title">Privacy Policy</h2>
|
||||
<div class="content">
|
||||
{{ site.privacy_policy | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,36 +1,5 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
|
||||
{% include 'discover/icons.html' %}
|
||||
|
||||
{% if not request.user.is_authenticated %}
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
<div class="tile is-child box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
<div class="tile is-child box has-background-primary-light content">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Join {{ site.name }}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">This instance is closed</h2>
|
||||
<p>{{ site.registration_closed_text | safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
{% extends 'discover/landing_layout.html' %}
|
||||
{% block panel %}
|
||||
|
||||
<div class="block is-hidden-tablet">
|
||||
<h2 class="title has-text-centered">Recent Books</h2>
|
||||
|
|
63
bookwyrm/templates/discover/landing_layout.html
Normal file
63
bookwyrm/templates/discover/landing_layout.html
Normal file
|
@ -0,0 +1,63 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load humanize %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
|
||||
{% include 'discover/icons.html' %}
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
<div class="tile is-child box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="tile is-child box has-background-primary-light content">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Join {{ site.name }}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">This instance is closed</h2>
|
||||
<p>{{ site.registration_closed_text | safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tile is-child box has-background-white-bis">
|
||||
<h2 class="title is-4">Your Account</h2>
|
||||
<div class="media block">
|
||||
<div class="media-left">
|
||||
<a href="{{ user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=user large=True %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p>{% if user.name %}{{ user.name }}{% else %}{{ user.localname }}{% endif %}</p>
|
||||
<p><a href="{{ user.remote_id }}">{{ user.username }}</a></p>
|
||||
<p>Joined {{ user.created_date | naturaltime }}</p>
|
||||
<p>
|
||||
<a href="{{ user.local_path }}/followers">{{ user.followers.count }} follower{{ user.followers.count | pluralize }}</a>,
|
||||
<a href="{{ user.local_path }}/following">{{ user.following.count }} following</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box content">
|
||||
{% if user.summary %}
|
||||
{{ user.summary | to_markdown | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
|
@ -23,6 +23,10 @@
|
|||
<label class="label" for="id_code_of_conduct">Code of conduct:</label>
|
||||
{{ site_form.code_of_conduct }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_privacy_policy">Privacy Policy:</label>
|
||||
{{ site_form.privacy_policy }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
{# user bio #}
|
||||
<div class="block">
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<div class="column is-two-fifths">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<a href="{{ user.local_path }}">
|
||||
|
@ -29,16 +29,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="column box has-background-white-bis content">
|
||||
{% if user.summary %}
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<span class="icon icon-quote-open"></span>
|
||||
</div>
|
||||
<div class="column">
|
||||
<blockquote>{{ user.summary | to_markdown | safe }}</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
{{ user.summary | to_markdown | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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:
|
||||
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,6 +216,9 @@ class Openlibrary(TestCase):
|
|||
'https://openlibrary.org/authors/OL382982A',
|
||||
json={'hi': 'there'},
|
||||
status=200)
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
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,
|
||||
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')
|
||||
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)
|
||||
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,
|
||||
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')
|
||||
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)
|
||||
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,
|
||||
with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid:
|
||||
result = self.client.post(
|
||||
'/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)
|
||||
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'):
|
||||
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_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(
|
||||
incoming.shared_inbox(request).status_code, 200)
|
||||
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_follow(self):
|
||||
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': {
|
||||
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"
|
||||
}}
|
||||
incoming.handle_unblock(activity)
|
||||
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(
|
||||
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