Merge branch 'main' into review-rate

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

View file

@ -112,36 +112,42 @@ Once the build is complete, you can access the instance at `localhost:1333`
## Installing in Production ## 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 momoment, very stable, so please procede with caution when running in production.
### Server setup ### Server setup
- Get a domain name and set up DNS for your server - 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 again Ubuntu 20.04)
- Set up a mailgun account and the appropriate DNS settings - Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
- Install Docker and docker-compose - Install Docker and docker-compose
### Install and configure BookWyrm ### 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.
Instructions for running BookWyrm in production:
- Get the application code: - Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git` `git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch - Switch to the `production` branch
`git checkout production` `git checkout production`
- Create your environment variables file - Create your environment variables file
`cp .env.example .env` `cp .env.example .env`
- Add your domain, email address, mailgun credentials - Add your domain, email address, SMTP credentials
- Set a secure redis password and secret key - Set a secure redis password and secret key
- Set a secure database password for postgres - Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf` - Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name - Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain) - Run the application (this should also set up a Certbot ssl cert for your domain) with
`docker-compose up --build` `docker-compose up --build`, and make sure all the images build successfully
Make sure all the images build successfully
- When docker has built successfully, stop the process with `CTRL-C` - When docker has built successfully, stop the process with `CTRL-C`
- Comment out the `command: certonly...` line in `docker-compose.yml` - Comment out the `command: certonly...` line in `docker-compose.yml`
- Run docker-compose in the background - Run docker-compose in the background with: `docker-compose up -d`
`docker-compose up -d` - Initialize the database with: `./bw-dev initdb`
- Initialize the database - Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe location
`./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi Congrats! You did it, go to your domain and enjoy the fruits of your labors.
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
### Configure your instance ### Configure your instance
- Register a user account in the applcation UI - Register a user account in the application UI
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command) - Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
- On your server, open the django shell - On your server, open the django shell
`./bw-dev shell` `./bw-dev shell`

View file

@ -2,14 +2,13 @@
import inspect import inspect
import sys 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 Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Quotation from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating from .note import Review, Rating
from .note import Tombstone from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import BookList, Shelf from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey from .person import Person, PublicKey
@ -17,10 +16,15 @@ from .response import ActivitypubResponse
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block 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, # 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 # so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \ activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')} if hasattr(c[1], 'to_model')}
def parse(activity_json):
''' figure out what activity this is and parse it '''
return naive_parse(activity_objects, activity_json)

View file

@ -40,6 +40,20 @@ class Signature:
signatureValue: str signatureValue: str
type: str = 'RsaSignature2017' 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) @dataclass(init=False)
class ActivityObject: class ActivityObject:
@ -47,13 +61,30 @@ class ActivityObject:
id: str id: str
type: 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 ''' 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 dataclass, which it ignores. Any field in the dataclass is required or
has a default value ''' has a default value '''
for field in fields(self): for field in fields(self):
try: try:
value = kwargs[field.name] 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: except KeyError:
if field.default == MISSING and \ if field.default == MISSING and \
field.default_factory == MISSING: field.default_factory == MISSING:
@ -63,31 +94,29 @@ class ActivityObject:
setattr(self, field.name, value) 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 ''' ''' convert from an activity to a model instance '''
if self.type != model.activity_serializer.type: model = model or get_model_from_type(self.type)
raise ActivitySerializerError(
'Wrong activity type "%s" for activity of type "%s"' % \
(model.activity_serializer.type,
self.type)
)
if not isinstance(self, model.activity_serializer): # only reject statuses if we're potentially creating them
raise ActivitySerializerError( if allow_create and \
'Wrong activity type "%s" for model "%s" (expects "%s")' % \ hasattr(model, 'ignore_activity') and \
(self.__class__, model.ignore_activity(self):
model.__name__, return None
model.activity_serializer)
)
if hasattr(model, 'ignore_activity') and model.ignore_activity(self): # check for an existing instance
return instance instance = instance or model.find_existing(self.serialize())
# check for an existing instance, if we're not updating a known obj if not instance and not allow_create:
instance = instance or model.find_existing(self.serialize()) or model() # 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: for field in instance.simple_fields:
field.set_field_from_activity(instance, self) try:
field.set_field_from_activity(instance, self)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save # image fields have to be set after other fields because they can save
# too early and jank up users # too early and jank up users
@ -139,7 +168,14 @@ class ActivityObject:
def serialize(self): def serialize(self):
''' convert to dictionary with context attr ''' ''' 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 = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams' data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data return data
@ -182,7 +218,7 @@ def set_related_field(
getattr(model_field, 'activitypub_field'), getattr(model_field, 'activitypub_field'),
instance.remote_id instance.remote_id
) )
item = activity.to_model(model) item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then # if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation # we have to set it post-creation
@ -191,11 +227,24 @@ def set_related_field(
item.save() 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 ''' ''' take a remote_id and return an instance, creating if necessary '''
result = model.find_existing_by_remote_id(remote_id) if model:# a bonus check we can do if we already know the model
if result and not refresh: result = model.find_existing_by_remote_id(remote_id)
return result if result and not refresh:
return result
# load the data and create the object # load the data and create the object
try: try:
@ -204,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True):
raise ActivitySerializerError( raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \ 'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id)) (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 # check for existing items with shared unique identifiers
if not result: result = model.find_existing(data)
result = model.find_existing(data) if result and not refresh:
if result and not refresh: return result
return result
item = model.activity_serializer(**data) item = model.activity_serializer(**data)
# if we're refreshing, "result" will be set and we'll update it # if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save) return item.to_model(model=model, instance=result, save=save)

View file

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

View file

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

View file

@ -1,6 +1,7 @@
''' note serializer and children thereof ''' ''' note serializer and children thereof '''
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link from .base_activity import ActivityObject, Link
from .image import Image from .image import Image
@ -8,10 +9,13 @@ from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
''' the placeholder for a deleted status ''' ''' the placeholder for a deleted status '''
published: str
deleted: str
type: str = 'Tombstone' 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) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):

View file

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

View file

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

View file

@ -1,10 +1,12 @@
''' undo wrapper activity ''' ''' undo wrapper activity '''
from dataclasses import dataclass from dataclasses import dataclass
from typing import List 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 from .book import Edition
@dataclass(init=False) @dataclass(init=False)
class Verb(ActivityObject): class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of ''' generic fields for activities - maybe an unecessary level of
@ -12,6 +14,10 @@ class Verb(ActivityObject):
actor: str actor: str
object: ActivityObject object: ActivityObject
def action(self):
''' usually we just want to save, this can be overridden as needed '''
self.object.to_model()
@dataclass(init=False) @dataclass(init=False)
class Create(Verb): class Create(Verb):
@ -29,6 +35,12 @@ class Delete(Verb):
cc: List cc: List
type: str = 'Delete' 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) @dataclass(init=False)
class Update(Verb): class Update(Verb):
@ -36,29 +48,60 @@ class Update(Verb):
to: List to: List
type: str = 'Update' type: str = 'Update'
def action(self):
''' update a model instance from the dataclass '''
self.object.to_model(allow_create=False)
@dataclass(init=False) @dataclass(init=False)
class Undo(Verb): class Undo(Verb):
''' Undo an activity ''' ''' Undo an activity '''
type: str = 'Undo' 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) @dataclass(init=False)
class Follow(Verb): class Follow(Verb):
''' Follow activity ''' ''' Follow activity '''
object: str
type: str = 'Follow' type: str = 'Follow'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Block(Verb): class Block(Verb):
''' Block activity ''' ''' Block activity '''
object: str
type: str = 'Block' type: str = 'Block'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Accept(Verb): class Accept(Verb):
''' Accept activity ''' ''' Accept activity '''
object: Follow object: Follow
type: str = 'Accept' 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) @dataclass(init=False)
class Reject(Verb): class Reject(Verb):
@ -66,32 +109,60 @@ class Reject(Verb):
object: Follow object: Follow
type: str = 'Reject' 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) @dataclass(init=False)
class Add(Verb): class Add(Verb):
'''Add activity ''' '''Add activity '''
target: str target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Add):
'''Add activity that's aware of the book obj '''
object: Edition object: Edition
type: str = 'Add' type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None notes: str = None
order: int = 0 order: int = 0
approved: bool = True 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) @dataclass(init=False)
class Remove(Verb): class Remove(Verb):
'''Remove activity ''' '''Remove activity '''
target: ActivityObject target: ActivityObject
type: str = 'Remove' type: str = 'Remove'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Like(Verb):
''' a user faving an object '''
object: str
type: str = 'Like'
def action(self):
''' like '''
self.to_model()
@dataclass(init=False)
class Announce(Verb):
''' boosting a status '''
object: str
type: str = 'Announce'
def action(self):
''' boost '''
self.to_model()

View file

@ -127,7 +127,7 @@ class AbstractConnector(AbstractMinimalConnector):
# create activitypub object # create activitypub object
work_activity = activitypub.Work(**work_data) work_activity = activitypub.Work(**work_data)
# this will dedupe automatically # 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): for author in self.get_authors_from_data(data):
work.authors.add(author) work.authors.add(author)
@ -141,7 +141,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id mapped_data['work'] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data) 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.connector = self.connector
edition.save() edition.save()
@ -168,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(data, self.author_mappings) mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data) activity = activitypub.Author(**mapped_data)
# this will dedupe # this will dedupe
return activity.to_model(models.Author) return activity.to_model(model=models.Author)
@abstractmethod @abstractmethod
@ -216,11 +216,7 @@ def get_data(url):
raise ConnectorException() raise ConnectorException()
if not resp.ok: if not resp.ok:
try: raise ConnectorException()
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try: try:
data = resp.json() data = resp.json()
except ValueError as e: except ValueError as e:

View file

@ -7,7 +7,7 @@ class Connector(AbstractMinimalConnector):
''' this is basically just for search ''' ''' this is basically just for search '''
def get_or_create_book(self, remote_id): 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 = edition.parent_work
work.default_edition = work.get_default_edition() work.default_edition = work.get_default_edition()
work.save() work.save()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
''' models for storing different kinds of Activities ''' ''' models for storing different kinds of Activities '''
import urllib.parse import urllib.parse
from django.apps import apps
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
@ -15,17 +16,15 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
name = fields.CharField(max_length=100, unique=True) name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100) 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 @property
def collection_queryset(self): def books(self):
''' books associated with this tag ''' ''' count of books associated with this tag '''
return self.book_queryset(self.identifier) 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): def get_remote_id(self):
''' tag should use identifier not id in remote_id ''' ''' tag should use identifier not id in remote_id '''
@ -50,7 +49,7 @@ class UserTag(CollectionItemMixin, BookWyrmModel):
tag = fields.ForeignKey( tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target') 'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook activity_serializer = activitypub.Add
object_field = 'book' object_field = 'book'
collection_field = 'tag' collection_field = 'tag'

View file

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

View file

@ -100,9 +100,6 @@
.cover-container.is-medium { .cover-container.is-medium {
height: 100px; height: 100px;
} }
.cover-container.is-small {
height: 70px;
}
} }
.cover-container.is-medium .no-cover div { .cover-container.is-medium .no-cover div {

View file

@ -1,16 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns">
<div class="column block">
{% include 'snippets/about.html' %}
</div>
<div class="column block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
</div>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<div class="columns"> <div class="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title">{{ author.name }}</h1> <h1 class="title">{{ author.name }}</h1>
</div> </div>

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="block"> <div class="block">
<div class="columns"> <div class="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title"> <h1 class="title">
{{ book.title }}{% if book.subtitle %}: {{ book.title }}{% if book.subtitle %}:
@ -42,26 +42,10 @@
<h3 class="title is-6 mb-1">Add cover</h3> <h3 class="title is-6 mb-1">Add cover</h3>
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data"> <form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="field has-addons"> <label class="label">
<div class="control"> <input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
<div class="file is-small mb-1"> </label>
<label class="file-label"> <button class="button is-small is-primary" type="submit">Add</button>
<input class="file-input" type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose file...
</span>
</span>
</label>
</div>
</div>
<div class="control">
<button class="button is-small is-primary" type="submit">Add</button>
</div>
</div>
</form> </form>
</div> </div>
{% endif %} {% endif %}
@ -242,7 +226,7 @@
<div class="block" id="reviews"> <div class="block" id="reviews">
{% for review in reviews %} {% for review in reviews %}
<div class="block"> <div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %} {% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
</div> </div>
{% endfor %} {% endfor %}

View file

@ -0,0 +1,22 @@
{% 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' %}
<section class="block">
{% include 'snippets/about.html' %}
</section>
<div class="block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
{% endblock %}

View file

@ -1,33 +1,14 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% block content %} {% block content %}
{% if not request.user.is_authenticated %}
<header class="block has-text-centered"> <header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1> <h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2> <h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header> </header>
<section class="level is-mobile"> {% include 'discover/icons.html' %}
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>
{% if not request.user.is_authenticated %}
<section class="tile is-ancestor"> <section class="tile is-ancestor">
<div class="tile is-7 is-parent"> <div class="tile is-7 is-parent">
<div class="tile is-child box"> <div class="tile is-child box">
@ -49,10 +30,6 @@
</div> </div>
</section> </section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>
</div>
{% endif %} {% endif %}
<div class="block is-hidden-tablet"> <div class="block is-hidden-tablet">
@ -63,18 +40,18 @@
<div class="tile is-vertical"> <div class="tile is-vertical">
<div class="tile is-parent"> <div class="tile is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.0 %} {% include 'discover/large-book.html' with book=books.0 %}
</div> </div>
</div> </div>
<div class="tile"> <div class="tile">
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.1 %} {% include 'discover/small-book.html' with book=books.1 %}
</div> </div>
</div> </div>
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.2 %} {% include 'discover/small-book.html' with book=books.2 %}
</div> </div>
</div> </div>
</div> </div>
@ -83,18 +60,18 @@
<div class="tile"> <div class="tile">
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.3 %} {% include 'discover/small-book.html' with book=books.3 %}
</div> </div>
</div> </div>
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.4 %} {% include 'discover/small-book.html' with book=books.4 %}
</div> </div>
</div> </div>
</div> </div>
<div class="tile is-parent"> <div class="tile is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.5 %} {% include 'discover/large-book.html' with book=books.5 %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,21 @@
<section class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>

View file

@ -2,8 +2,8 @@
{% if book %} {% if book %}
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="large" %} <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="large" %}</a>
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} {% include 'snippets/stars.html' with rating=book|rating:request.user %}
</div> </div>
<div class="column"> <div class="column">
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3> <h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>

View file

@ -1,9 +1,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% if book %} {% if book %}
{% include 'snippets/book_cover.html' with book=book %} <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% if ratings %} {% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
{% endif %}
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3> <h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %} {% if book.authors %}

View file

@ -62,7 +62,7 @@
<div class="column is-narrow"> <div class="column is-narrow">
<div class="block"> <div class="block">
<h2 class="title is-4">Cover</h2> <h2 class="title is-4">Cover</h2>
<p>{{ form.cover }} </p> <p>{{ form.cover }}</p>
{% for error in form.cover.errors %} {% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}

View file

@ -16,7 +16,7 @@
{% endif %} {% endif %}
{% for activity in activities %} {% for activity in activities %}
<div class="block"> <div class="block">
{% include 'snippets/status.html' with status=activity %} {% include 'snippets/status/status.html' with status=activity %}
</div> </div>
{% endfor %} {% endfor %}

View file

@ -32,7 +32,7 @@
{% endif %} {% endif %}
{% for activity in activities %} {% for activity in activities %}
<div class="block"> <div class="block">
{% include 'snippets/status.html' with status=activity %} {% include 'snippets/status/status.html' with status=activity %}
</div> </div>
{% endfor %} {% endfor %}

View file

@ -8,7 +8,7 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% include 'snippets/status.html' with status=status main=is_root %} {% include 'snippets/status/status.html' with status=status main=is_root %}
{% if depth <= max_depth and direction >= 0 %} {% if depth <= max_depth and direction >= 0 %}
{% for reply in status|replies %} {% for reply in status|replies %}

View file

@ -50,7 +50,7 @@
<div class="column is-narrow"> <div class="column is-narrow">
<div class="box"> <div class="box">
<a href="{{ book.book.local_path }}"> <a href="{{ book.book.local_path }}">
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %} {% include 'discover/small-book.html' with book=book.book rating=goal.ratings %}
</a> </a>
</div> </div>
</div> </div>

View file

@ -19,7 +19,7 @@
{% for item in items %} {% for item in items %}
<li class="block pb-3"> <li class="block pb-3">
<div class="card"> <div class="card">
<div class="card-content columns p-0 mb-0"> <div class="card-content columns p-0 mb-0 is-mobile">
<div class="column is-narrow pt-0 pb-0"> <div class="column is-narrow pt-0 pb-0">
<a href="{{ item.book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a> <a href="{{ item.book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
</div> </div>
@ -73,7 +73,7 @@
{% endif %} {% endif %}
{% for book in suggested_books %} {% for book in suggested_books %}
{% if book %} {% if book %}
<div class="block columns"> <div class="block columns is-mobile">
<div class="column is-narrow"> <div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a> <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div> </div>

View file

@ -2,7 +2,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<header class="columns content"> <header class="columns content is-mobile">
<div class="column"> <div class="column">
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1> <h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p> <p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>

View file

@ -5,7 +5,7 @@
<h1 class="title">Lists</h1> <h1 class="title">Lists</h1>
</header> </header>
{% if request.user.is_authenticated and not lists.has_previous %} {% if request.user.is_authenticated and not lists.has_previous %}
<header class="block columns"> <header class="block columns is-mobile">
<div class="column"> <div class="column">
<h2 class="title">Your lists</h2> <h2 class="title">Your lists</h2>
</div> </div>

View file

@ -2,28 +2,6 @@
{% block header %}Invites{% endblock %} {% block header %}Invites{% endblock %}
{% load humanize %} {% load humanize %}
{% block panel %} {% block panel %}
<section class="block">
<table class="table is-striped">
<tr>
<th>Link</th>
<th>Expires</th>
<th>Max uses</th>
<th>Times used</th>
</tr>
{% if not invites %}
<tr><td colspan="4">No active invites</td></tr>
{% endif %}
{% for invite in invites %}
<tr>
<td><a href="{{ invite.link }}">{{ invite.link }}</td>
<td>{{ invite.expiry|naturaltime }}</td>
<td>{{ invite.use_limit }}</td>
<td>{{ invite.times_used }}</td>
</tr>
{% endfor %}
</table>
</section>
<section class="block"> <section class="block">
<h2 class="title is-4">Generate New Invite</h2> <h2 class="title is-4">Generate New Invite</h2>
@ -47,4 +25,27 @@
<button class="button is-primary" type="submit">Create Invite</button> <button class="button is-primary" type="submit">Create Invite</button>
</form> </form>
</section> </section>
<section class="block">
<table class="table is-striped">
<tr>
<th>Link</th>
<th>Expires</th>
<th>Max uses</th>
<th>Times used</th>
</tr>
{% if not invites %}
<tr><td colspan="4">No active invites</td></tr>
{% endif %}
{% for invite in invites %}
<tr>
<td><a href="{{ invite.link }}">{{ invite.link }}</td>
<td>{{ invite.expiry|naturaltime }}</td>
<td>{{ invite.use_limit }}</td>
<td>{{ invite.times_used }}</td>
</tr>
{% endfor %}
</table>
{% include 'snippets/pagination.html' with page=invites path=request.path %}
</section>
{% endblock %} {% endblock %}

View file

@ -11,7 +11,7 @@
</form> </form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small is-success" type="submit"> <button class="button is-small is-primary" type="submit">
<span class="icon icon-boost" title="Un-boost status"> <span class="icon icon-boost" title="Un-boost status">
<span class="is-sr-only">Un-boost status</span> <span class="is-sr-only">Un-boost status</span>
</span> </span>

View file

@ -10,7 +10,7 @@
</form> </form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-success is-small" type="submit"> <button class="button is-primary is-small" type="submit">
<span class="icon icon-heart" title="Un-like status"> <span class="icon icon-heart" title="Un-like status">
<span class="is-sr-only">Un-like status</span> <span class="is-sr-only">Un-like status</span>
</span> </span>

View file

@ -5,20 +5,29 @@
Follow request already sent. Follow request already sent.
</div> </div>
{% elif user in request.user.blocks.all %}
{% include 'snippets/block_button.html' %}
{% else %} {% else %}
<form action="/follow/" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}"> <div class="field has-addons">
{% csrf_token %} <div class="control">
<input type="hidden" name="user" value="{{ user.username }}"> <form action="/follow/" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
{% if user.manually_approves_followers %} {% csrf_token %}
<button class="button is-small is-link" type="submit">Send follow request</button> <input type="hidden" name="user" value="{{ user.username }}">
{% else %} {% if user.manually_approves_followers %}
<button class="button is-small is-link" type="submit">Follow</button> <button class="button is-small is-link" type="submit">Send follow request</button>
{% endif %} {% else %}
</form> <button class="button is-small is-link" type="submit">Follow</button>
<form action="/unfollow/" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}"> {% endif %}
{% csrf_token %} </form>
<input type="hidden" name="user" value="{{ user.username }}"> <form action="/unfollow/" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<button class="button is-small is-danger is-light" type="submit">Unfollow</button> {% csrf_token %}
</form> <input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small is-danger is-light" type="submit">Unfollow</button>
</form>
</div>
<div class="control">
{% include 'snippets/user_options.html' with user=user class="is-small" %}
</div>
</div>
{% endif %} {% endif %}

View file

@ -9,7 +9,7 @@
<input type="hidden" name="privacy" value="public"> <input type="hidden" name="privacy" value="public">
<input type="hidden" name="rating" value="{{ forloop.counter }}"> <input type="hidden" name="rating" value="{{ forloop.counter }}">
<div class="field is-grouped stars form-rate-stars mb-1"> <div class="field is-grouped stars form-rate-stars mb-1 has-text-warning-dark">
<label class="is-sr-only" for="rating-no-rating-{{ book.id }}">No rating</label> <label class="is-sr-only" for="rating-no-rating-{{ book.id }}">No rating</label>
<input class="is-sr-only" type="radio" name="rating" value="" id="rating-no-rating-{{ book.id }}" checked> <input class="is-sr-only" type="radio" name="rating" value="" id="rating-no-rating-{{ book.id }}" checked>
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}

View file

@ -1,6 +1,7 @@
{% load humanize %} {% load humanize %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% if books|length > 0 %} {% if books|length > 0 %}
<div class="table-container">
<table class="table is-striped is-fullwidth"> <table class="table is-striped is-fullwidth">
<tr class="book-preview"> <tr class="book-preview">
@ -74,6 +75,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
{% else %} {% else %}
<p>This shelf is empty.</p> <p>This shelf is empty.</p>
{% if shelf.editable %} {% if shelf.editable %}

View file

@ -1,7 +1,7 @@
<p class="stars"> <p class="stars">
<span class="is-sr-only">{% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %}</span> <span class="is-sr-only">{% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %}</span>
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}
<span class="icon icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}" aria-hidden="true"> <span class="icon is-small mr-1 icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}" aria-hidden="true">
</span> </span>
{% endfor %} {% endfor %}
</p> </p>

View file

@ -3,6 +3,7 @@
<div class="column is-narrow"> <div class="column is-narrow">
<div> <div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a> <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %} {% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div> </div>
</div> </div>

View file

@ -4,8 +4,8 @@
{% include 'snippets/avatar.html' with user=status.user %} {% include 'snippets/avatar.html' with user=status.user %}
{% include 'snippets/username.html' with user=status.user %} {% include 'snippets/username.html' with user=status.user %}
boosted boosted
{% include 'snippets/status_body.html' with status=status|boosted_status %} {% include 'snippets/status/status_body.html' with status=status|boosted_status %}
{% else %} {% else %}
{% include 'snippets/status_body.html' with status=status %} {% include 'snippets/status/status_body.html' with status=status %}
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -5,13 +5,13 @@
{% block card-header %} {% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block"> <h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status_header.html' with status=status %} {% include 'snippets/status/status_header.html' with status=status %}
</h3> </h3>
{% endblock %} {% endblock %}
{% block card-content %} {% block card-content %}
{% include 'snippets/status_content.html' with status=status %} {% include 'snippets/status/status_content.html' with status=status %}
{% endblock %} {% endblock %}
@ -55,7 +55,7 @@
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a> <a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
{% include 'snippets/status_options.html' with class="is-small" right=True %} {% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -54,9 +54,9 @@
{% if status.book or status.mention_books.count %} {% if status.book or status.mention_books.count %}
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}"> <div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}">
{% if status.book %} {% if status.book %}
{% include 'snippets/book_preview.html' with book=status.book %} {% include 'snippets/status/book_preview.html' with book=status.book %}
{% elif status.mention_books.count %} {% elif status.mention_books.count %}
{% include 'snippets/book_preview.html' with book=status.mention_books.first %} {% include 'snippets/status/book_preview.html' with book=status.mention_books.first %}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View file

@ -15,17 +15,13 @@
<div class="block"> <div class="block">
<h2 class="title">Followers</h2> <h2 class="title">Followers</h2>
{% for followers in followers %} {% for followers in followers %}
<div class="block"> <div class="block columns">
<div class="field is-grouped"> <div class="column">
<div class="control"> {% include 'snippets/avatar.html' with user=followers %}
{% include 'snippets/avatar.html' with user=followers %} {% include 'snippets/username.html' with user=followers show_full=True %}
</div> </div>
<div class="control"> <div class="column is-narrow">
{% include 'snippets/username.html' with user=followers show_full=True %} {% include 'snippets/follow_button.html' with user=followers %}
</div>
<div class="control">
{% include 'snippets/follow_button.html' with user=followers %}
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -15,17 +15,13 @@
<div class="block"> <div class="block">
<h2 class="title">Following</h2> <h2 class="title">Following</h2>
{% for follower in user.following.all %} {% for follower in user.following.all %}
<div class="block"> <div class="block columns">
<div class="field is-grouped"> <div class="column">
<div class="control"> {% include 'snippets/avatar.html' with user=follower %}
{% include 'snippets/avatar.html' with user=follower %} {% include 'snippets/username.html' with user=follower show_full=True %}
</div> </div>
<div class="control"> <div class="column">
{% include 'snippets/username.html' with user=follower show_full=True %} {% include 'snippets/follow_button.html' with user=follower %}
</div>
<div class="control">
{% include 'snippets/follow_button.html' with user=follower %}
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -1,7 +1,7 @@
{% extends 'user/user_layout.html' %} {% extends 'user/user_layout.html' %}
{% block header %} {% block header %}
<div class="columns"> <div class="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title"> <h1 class="title">
{% if is_self %}Your {% if is_self %}Your

View file

@ -38,7 +38,7 @@
{% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %} {% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %}
</div> </div>
<div class="block columns"> <div class="block columns is-mobile">
<div class="column"> <div class="column">
<h2 class="title is-3"> <h2 class="title is-3">
{{ shelf.name }} {{ shelf.name }}

View file

@ -1,7 +1,7 @@
{% extends 'user/user_layout.html' %} {% extends 'user/user_layout.html' %}
{% block header %} {% block header %}
<div class="columns"> <div class="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title">User profile</h1> <h1 class="title">User profile</h1>
</div> </div>
@ -54,7 +54,7 @@
{% endif %} {% endif %}
<div> <div>
<div class="columns"> <div class="columns is-mobile">
<h2 class="title column">User Activity</h2> <h2 class="title column">User Activity</h2>
<div class="column is-narrow"> <div class="column is-narrow">
<a class="icon icon-rss" target="_blank" href="{{ user.local_path }}/rss"> <a class="icon icon-rss" target="_blank" href="{{ user.local_path }}/rss">
@ -64,7 +64,7 @@
</div> </div>
{% for activity in activities %} {% for activity in activities %}
<div class="block" id="feed"> <div class="block" id="feed">
{% include 'snippets/status.html' with status=activity %} {% include 'snippets/status/status.html' with status=activity %}
</div> </div>
{% endfor %} {% endfor %}
{% if not activities %} {% if not activities %}

View file

@ -43,14 +43,7 @@
</div> </div>
</div> </div>
{% if not is_self and request.user.is_authenticated %} {% if not is_self and request.user.is_authenticated %}
<div class="field has-addons"> {% include 'snippets/follow_button.html' with user=user %}
<div class="control">
{% include 'snippets/follow_button.html' with user=user %}
</div>
<div class="control">
{% include 'snippets/user_options.html' with user=user class="is-small" %}
</div>
</div>
{% endif %} {% endif %}
{% if is_self and user.follower_requests.all %} {% if is_self and user.follower_requests.all %}

View file

@ -22,11 +22,8 @@ def dict_key(d, k):
@register.filter(name='rating') @register.filter(name='rating')
def get_rating(book, user): def get_rating(book, user):
''' get the overall rating of a book ''' ''' get the overall rating of a book '''
queryset = views.helpers.get_activity_feed( queryset = views.helpers.privacy_filter(
user, user, models.Review.objects.filter(book=book))
['public', 'followers', 'unlisted', 'direct'],
queryset=models.Review.objects.filter(book=book),
)
return queryset.aggregate(Avg('rating'))['rating__avg'] return queryset.aggregate(Avg('rating'))['rating__avg']

View file

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

View file

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

View file

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

View file

@ -92,11 +92,26 @@ class Openlibrary(TestCase):
responses.add( responses.add(
responses.GET, responses.GET,
'https://openlibrary.org/authors/OL382982A', '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) status=200)
results = self.connector.get_authors_from_data(self.work_data) results = self.connector.get_authors_from_data(self.work_data)
for result in results: result = list(results)[0]
self.assertIsInstance(result, models.Author) self.assertIsInstance(result, models.Author)
self.assertEqual(result.name, 'George Elliott')
self.assertEqual(result.openlibrary_key, 'OL453734A')
def test_get_cover_url(self): def test_get_cover_url(self):
@ -201,8 +216,11 @@ class Openlibrary(TestCase):
'https://openlibrary.org/authors/OL382982A', 'https://openlibrary.org/authors/OL382982A',
json={'hi': 'there'}, json={'hi': 'there'},
status=200) status=200)
result = self.connector.create_edition_from_data( with patch('bookwyrm.connectors.openlibrary.Connector.' \
work, self.edition_data) 'get_authors_from_data') as mock:
mock.return_value = []
result = self.connector.create_edition_from_data(
work, self.edition_data)
self.assertEqual(result.parent_work, work) self.assertEqual(result.parent_work, work)
self.assertEqual(result.title, 'Sabriel') self.assertEqual(result.title, 'Sabriel')
self.assertEqual(result.isbn_10, '0060273224') self.assertEqual(result.isbn_10, '0060273224')

View file

@ -30,6 +30,12 @@ class ActivitypubMixins(TestCase):
outbox='https://example.com/users/rat/outbox', 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 # ActivitypubMixin
def test_to_activity(self): def test_to_activity(self):
@ -290,40 +296,12 @@ class ActivitypubMixins(TestCase):
id=1, user=self.local_user, deleted=True).save() 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): def test_to_delete_activity(self):
''' wrapper for Delete activity ''' ''' wrapper for Delete activity '''
MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf( mock_self = MockSelf(
'https://example.com/status/1', 'https://example.com/status/1',
lambda *args: {} lambda *args: self.object_mock
) )
activity = ObjectMixin.to_delete_activity( activity = ObjectMixin.to_delete_activity(
mock_self, self.local_user) mock_self, self.local_user)
@ -346,7 +324,7 @@ class ActivitypubMixins(TestCase):
MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf( mock_self = MockSelf(
'https://example.com/status/1', 'https://example.com/status/1',
lambda *args: {} lambda *args: self.object_mock
) )
activity = ObjectMixin.to_update_activity( activity = ObjectMixin.to_update_activity(
mock_self, self.local_user) mock_self, self.local_user)
@ -361,7 +339,7 @@ class ActivitypubMixins(TestCase):
self.assertEqual( self.assertEqual(
activity['to'], activity['to'],
['https://www.w3.org/ns/activitystreams#Public']) ['https://www.w3.org/ns/activitystreams#Public'])
self.assertEqual(activity['object'], {}) self.assertIsInstance(activity['object'], dict)
# Activity mixin # Activity mixin
@ -370,7 +348,7 @@ class ActivitypubMixins(TestCase):
MockSelf = namedtuple('Self', ('remote_id', 'to_activity', 'user')) MockSelf = namedtuple('Self', ('remote_id', 'to_activity', 'user'))
mock_self = MockSelf( mock_self = MockSelf(
'https://example.com/status/1', 'https://example.com/status/1',
lambda *args: {}, lambda *args: self.object_mock,
self.local_user, self.local_user,
) )
activity = ActivityMixin.to_undo_activity(mock_self) 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['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Undo') self.assertEqual(activity['type'], 'Undo')
self.assertEqual(activity['object'], {}) self.assertIsInstance(activity['object'], dict)

View file

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

View file

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

View file

@ -23,19 +23,6 @@ class Relationship(TestCase):
self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save(broadcast=False) 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): def test_user_follows_from_request(self):
''' convert a follow request into a follow ''' ''' 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(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Accept') self.assertEqual(activity['type'], 'Accept')
self.assertEqual(activity['actor'], self.local_user.remote_id) self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual( self.assertEqual(activity['object']['id'], 'https://www.hi.com/')
activity['object']['id'], request.remote_id)
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
models.UserFollowRequest.broadcast = mock_broadcast models.UserFollowRequest.broadcast = mock_broadcast
request = models.UserFollowRequest.objects.create( request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user, user_subject=self.remote_user,
user_object=self.local_user, user_object=self.local_user,
remote_id='https://www.hi.com/'
) )
request.accept() request.accept()
@ -145,6 +134,8 @@ class Relationship(TestCase):
activity['object']['id'], request.remote_id) activity['object']['id'], request.remote_id)
models.UserFollowRequest.broadcast = mock_reject models.UserFollowRequest.broadcast = mock_reject
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
request = models.UserFollowRequest.objects.create( request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user, user_subject=self.remote_user,
user_object=self.local_user, user_object=self.local_user,

View file

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

View file

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

View file

@ -1,16 +1,18 @@
''' testing models ''' ''' testing models '''
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
import responses
from bookwyrm import models from bookwyrm import models
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
# pylint: disable=missing-class-docstring
# pylint: disable=missing-function-docstring
class User(TestCase): class User(TestCase):
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
'mouse@%s' % DOMAIN, 'mouse@mouse.mouse', 'mouseword', 'mouse@%s' % DOMAIN, 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse', name='hi') local=True, localname='mouse', name='hi', bookwyrm_user=False)
def test_computed_fields(self): def test_computed_fields(self):
''' username instead of id here ''' ''' username instead of id here '''
@ -28,7 +30,7 @@ class User(TestCase):
with patch('bookwyrm.models.user.set_remote_server.delay'): with patch('bookwyrm.models.user.set_remote_server.delay'):
user = models.User.objects.create_user( user = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=False, 'rat', 'rat@rat.rat', 'ratword', local=False,
remote_id='https://example.com/dfjkg') remote_id='https://example.com/dfjkg', bookwyrm_user=False)
self.assertEqual(user.username, 'rat@example.com') self.assertEqual(user.username, 'rat@example.com')
@ -62,7 +64,7 @@ class User(TestCase):
self.assertEqual(activity['name'], self.user.name) self.assertEqual(activity['name'], self.user.name)
self.assertEqual(activity['inbox'], self.user.inbox) self.assertEqual(activity['inbox'], self.user.inbox)
self.assertEqual(activity['outbox'], self.user.outbox) self.assertEqual(activity['outbox'], self.user.outbox)
self.assertEqual(activity['bookwyrmUser'], True) self.assertEqual(activity['bookwyrmUser'], False)
self.assertEqual(activity['discoverable'], True) self.assertEqual(activity['discoverable'], True)
self.assertEqual(activity['type'], 'Person') self.assertEqual(activity['type'], 'Person')
@ -71,3 +73,83 @@ class User(TestCase):
self.assertEqual(activity['type'], 'OrderedCollection') self.assertEqual(activity['type'], 'OrderedCollection')
self.assertEqual(activity['id'], self.user.outbox) self.assertEqual(activity['id'], self.user.outbox)
self.assertEqual(activity['totalItems'], 0) self.assertEqual(activity['totalItems'], 0)
def test_set_remote_server(self):
server = models.FederatedServer.objects.create(
server_name=DOMAIN,
application_type='test type',
application_version=3
)
models.user.set_remote_server(self.user.id)
self.user.refresh_from_db()
self.assertEqual(self.user.federated_server, server)
@responses.activate
def test_get_or_create_remote_server(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
json={'software': {'name': 'hi', 'version': '2'}},
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertEqual(server.application_type, 'hi')
self.assertEqual(server.application_version, '2')
@responses.activate
def test_get_or_create_remote_server_no_wellknown(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
status=404
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)
@responses.activate
def test_get_or_create_remote_server_no_links(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
status=404
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)
@responses.activate
def test_get_or_create_remote_server_unknown_format(self):
responses.add(
responses.GET,
'https://%s/.well-known/nodeinfo' % DOMAIN,
json={'links': [{'href': 'http://www.example.com'}, {}]}
)
responses.add(
responses.GET,
'http://www.example.com',
json={'fish': 'salmon'}
)
server = models.user.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version)

View file

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

View file

@ -77,7 +77,6 @@ class BookViews(TestCase):
self.assertEqual(rel.status, 'follow_request') self.assertEqual(rel.status, 'follow_request')
def test_handle_follow_local(self): def test_handle_follow_local(self):
''' send a follow request ''' ''' send a follow request '''
rat = models.User.objects.create_user( rat = models.User.objects.create_user(
@ -105,14 +104,18 @@ class BookViews(TestCase):
request.user = self.local_user request.user = self.local_user
self.remote_user.followers.add(self.local_user) self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1) 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) views.unfollow(request)
self.assertEqual(mock.call_count, 1)
self.assertEqual(self.remote_user.followers.count(), 0) self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self): def test_handle_accept(self):
''' accept a follow request ''' ''' 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 = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user request.user = self.local_user
rel = models.UserFollowRequest.objects.create( rel = models.UserFollowRequest.objects.create(
@ -132,6 +135,8 @@ class BookViews(TestCase):
def test_handle_reject(self): def test_handle_reject(self):
''' reject a follow request ''' ''' 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 = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user request.user = self.local_user
rel = models.UserFollowRequest.objects.create( rel = models.UserFollowRequest.objects.create(

View file

@ -1,5 +1,6 @@
''' test for app action functionality ''' ''' test for app action functionality '''
from unittest.mock import patch from unittest.mock import patch
from django.utils import timezone
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -30,6 +31,7 @@ class GoalViews(TestCase):
) )
self.anonymous_user = AnonymousUser self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_goal_page_no_goal(self): def test_goal_page_no_goal(self):
@ -48,6 +50,7 @@ class GoalViews(TestCase):
request.user = self.local_user request.user = self.local_user
result = view(request, self.local_user.localname, 2020) result = view(request, self.local_user.localname, 2020)
result.render()
self.assertIsInstance(result, TemplateResponse) self.assertIsInstance(result, TemplateResponse)
@ -62,16 +65,23 @@ class GoalViews(TestCase):
def test_goal_page_public(self): def test_goal_page_public(self):
''' view a user's public goal ''' ''' view a user's public goal '''
models.ReadThrough.objects.create(
finish_date=timezone.now(),
user=self.local_user,
book=self.book,
)
models.AnnualGoal.objects.create( models.AnnualGoal.objects.create(
user=self.local_user, user=self.local_user,
year=2020, year=timezone.now().year,
goal=128937123, goal=128937123,
privacy='public') privacy='public')
view = views.Goal.as_view() view = views.Goal.as_view()
request = self.factory.get('') request = self.factory.get('')
request.user = self.rat request.user = self.rat
result = view(request, self.local_user.localname, 2020) result = view(request, self.local_user.localname, timezone.now().year)
result.render()
self.assertIsInstance(result, TemplateResponse) self.assertIsInstance(result, TemplateResponse)
def test_goal_page_private(self): def test_goal_page_private(self):

View file

@ -56,12 +56,14 @@ class ViewsHelpers(TestCase):
def test_get_user_from_username(self): def test_get_user_from_username(self):
''' works for either localname or username ''' ''' works for either localname or username '''
self.assertEqual( self.assertEqual(
views.helpers.get_user_from_username('mouse'), self.local_user) views.helpers.get_user_from_username(
self.local_user, 'mouse'), self.local_user)
self.assertEqual( self.assertEqual(
views.helpers.get_user_from_username( views.helpers.get_user_from_username(
'mouse@local.com'), self.local_user) self.local_user, 'mouse@local.com'), self.local_user)
with self.assertRaises(models.User.DoesNotExist): with self.assertRaises(models.User.DoesNotExist):
views.helpers.get_user_from_username('mojfse@example.com') views.helpers.get_user_from_username(
self.local_user, 'mojfse@example.com')
def test_is_api_request(self): def test_is_api_request(self):
@ -104,7 +106,7 @@ class ViewsHelpers(TestCase):
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, self.local_user,
['public', 'unlisted', 'followers'], privacy=['public', 'unlisted', 'followers'],
following_only=True, following_only=True,
queryset=models.Comment.objects queryset=models.Comment.objects
) )
@ -113,20 +115,21 @@ class ViewsHelpers(TestCase):
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, self.local_user,
['public', 'followers'], privacy=['public', 'followers'],
local_only=True local_only=True
) )
self.assertEqual(len(statuses), 2) self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status) self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_public) self.assertEqual(statuses[0], rat_public)
statuses = views.helpers.get_activity_feed(self.local_user, 'direct') statuses = views.helpers.get_activity_feed(
self.local_user, privacy=['direct'])
self.assertEqual(len(statuses), 1) self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], direct_status) self.assertEqual(statuses[0], direct_status)
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, self.local_user,
['public', 'followers'], privacy=['public', 'followers'],
) )
self.assertEqual(len(statuses), 3) self.assertEqual(len(statuses), 3)
self.assertEqual(statuses[2], public_status) self.assertEqual(statuses[2], public_status)
@ -135,7 +138,7 @@ class ViewsHelpers(TestCase):
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, self.local_user,
['public', 'unlisted', 'followers'], privacy=['public', 'unlisted', 'followers'],
following_only=True following_only=True
) )
self.assertEqual(len(statuses), 2) self.assertEqual(len(statuses), 2)
@ -145,7 +148,7 @@ class ViewsHelpers(TestCase):
rat.followers.add(self.local_user) rat.followers.add(self.local_user)
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, self.local_user,
['public', 'unlisted', 'followers'], privacy=['public', 'unlisted', 'followers'],
following_only=True following_only=True
) )
self.assertEqual(len(statuses), 5) self.assertEqual(len(statuses), 5)
@ -168,18 +171,18 @@ class ViewsHelpers(TestCase):
content='blah blah', user=rat) content='blah blah', user=rat)
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, ['public']) self.local_user, privacy=['public'])
self.assertEqual(len(statuses), 2) self.assertEqual(len(statuses), 2)
# block relationship # block relationship
rat.blocks.add(self.local_user) rat.blocks.add(self.local_user)
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
self.local_user, ['public']) self.local_user, privacy=['public'])
self.assertEqual(len(statuses), 1) self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], public_status) self.assertEqual(statuses[0], public_status)
statuses = views.helpers.get_activity_feed( statuses = views.helpers.get_activity_feed(
rat, ['public']) rat, privacy=['public'])
self.assertEqual(len(statuses), 1) self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], rat_public) self.assertEqual(statuses[0], rat_public)
@ -188,18 +191,18 @@ class ViewsHelpers(TestCase):
def test_is_bookwyrm_request(self): def test_is_bookwyrm_request(self):
''' checks if a request came from a bookwyrm instance ''' ''' checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'}) request = self.factory.get('', {'q': 'Test Book'})
self.assertFalse(views.helpers.is_bookworm_request(request)) self.assertFalse(views.helpers.is_bookwyrm_request(request))
request = self.factory.get( request = self.factory.get(
'', {'q': 'Test Book'}, '', {'q': 'Test Book'},
HTTP_USER_AGENT=\ HTTP_USER_AGENT=\
"http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)" "http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)"
) )
self.assertFalse(views.helpers.is_bookworm_request(request)) self.assertFalse(views.helpers.is_bookwyrm_request(request))
request = self.factory.get( request = self.factory.get(
'', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT) '', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
self.assertTrue(views.helpers.is_bookworm_request(request)) self.assertTrue(views.helpers.is_bookwyrm_request(request))
def test_existing_user(self): def test_existing_user(self):

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from bookwyrm import models, views from bookwyrm import models, views
from bookwyrm.settings import USER_AGENT
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
@ -90,3 +91,39 @@ class OutboxView(TestCase):
data = json.loads(result.content) data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection') self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 1) self.assertEqual(data['totalItems'], 1)
def test_outbox_bookwyrm_request_true(self):
''' should differentiate between bookwyrm and outside requests '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
name='hi',
content='look at this',
user=self.local_user,
book=self.book,
privacy='public',
)
request = self.factory.get('', {'page': 1}, HTTP_USER_AGENT=USER_AGENT)
result = views.Outbox.as_view()(request, 'mouse')
data = json.loads(result.content)
self.assertEqual(len(data['orderedItems']), 1)
self.assertEqual(data['orderedItems'][0]['type'], 'Review')
def test_outbox_bookwyrm_request_false(self):
''' should differentiate between bookwyrm and outside requests '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
name='hi',
content='look at this',
user=self.local_user,
book=self.book,
privacy='public',
)
request = self.factory.get('', {'page': 1})
result = views.Outbox.as_view()(request, 'mouse')
data = json.loads(result.content)
self.assertEqual(len(data['orderedItems']), 1)
self.assertEqual(data['orderedItems'][0]['type'], 'Article')

View file

@ -41,6 +41,7 @@ class RssFeedView(TestCase):
''' load an rss feed ''' ''' load an rss feed '''
view = rss_feed.RssFeed() view = rss_feed.RssFeed()
request = self.factory.get('/user/rss_user/rss') request = self.factory.get('/user/rss_user/rss')
request.user = self.user
with patch("bookwyrm.models.SiteSettings.objects.get") as site: with patch("bookwyrm.models.SiteSettings.objects.get") as site:
site.return_value = self.site site.return_value = self.site
result = view(request, username=self.user.username) result = view(request, username=self.user.username)

View file

@ -1,4 +1,5 @@
''' test for app action functionality ''' ''' test for app action functionality '''
import json
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -236,7 +237,11 @@ class StatusViews(TestCase):
self.assertFalse(status.deleted) self.assertFalse(status.deleted)
request = self.factory.post('') request = self.factory.post('')
request.user = self.local_user 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) 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() status.refresh_from_db()
self.assertTrue(status.deleted) self.assertTrue(status.deleted)

View file

@ -59,6 +59,21 @@ class TagViews(TestCase):
self.assertEqual(result.status_code, 200) 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): def test_tag(self):
''' add a tag to a book ''' ''' add a tag to a book '''
view = views.AddTag.as_view() view = views.AddTag.as_view()

View file

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

View file

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

View file

@ -45,15 +45,9 @@ class Book(View):
if not work: if not work:
return HttpResponseNotFound() return HttpResponseNotFound()
reviews = models.Review.objects.filter(
book__in=work.editions.all(),
)
# all reviews for the book # all reviews for the book
reviews = get_activity_feed( reviews = models.Review.objects.filter(book__in=work.editions.all())
request.user, reviews = get_activity_feed(request.user, queryset=reviews)
['public', 'unlisted', 'followers', 'direct'],
queryset=reviews
)
# the reviews to show # the reviews to show
paginated = Paginator(reviews.exclude( paginated = Paginator(reviews.exclude(
@ -96,9 +90,8 @@ class Book(View):
'rating': reviews.aggregate(Avg('rating'))['rating__avg'], 'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
'tags': models.UserTag.objects.filter(book=book), 'tags': models.UserTag.objects.filter(book=book),
'lists': privacy_filter( 'lists': privacy_filter(
request.user, request.user, book.list_set.all()
book.list_set.all(), ),
['public', 'unlisted', 'followers']),
'user_tags': user_tags, 'user_tags': user_tags,
'user_shelves': user_shelves, 'user_shelves': user_shelves,
'other_edition_shelves': other_edition_shelves, 'other_edition_shelves': other_edition_shelves,

View file

@ -11,9 +11,8 @@ from django.views import View
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed from .helpers import get_activity_feed, get_user_from_username
from .helpers import get_user_from_username from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user
from .helpers import is_api_request, is_bookworm_request, object_visible_to_user
# pylint: disable= no-self-use # pylint: disable= no-self-use
@ -29,14 +28,13 @@ class Feed(View):
if tab == 'home': if tab == 'home':
activities = get_activity_feed( activities = get_activity_feed(
request.user, ['public', 'unlisted', 'followers'], request.user, following_only=True)
following_only=True)
elif tab == 'local': elif tab == 'local':
activities = get_activity_feed( activities = get_activity_feed(
request.user, ['public', 'followers'], local_only=True) request.user, privacy=['public', 'followers'], local_only=True)
else: else:
activities = get_activity_feed( activities = get_activity_feed(
request.user, ['public', 'followers']) request.user, privacy=['public', 'followers'])
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
data = {**feed_page_data(request.user), **{ data = {**feed_page_data(request.user), **{
@ -65,14 +63,14 @@ class DirectMessage(View):
user = None user = None
if username: if username:
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
pass pass
if user: if user:
queryset = queryset.filter(Q(user=user) | Q(mention_users=user)) queryset = queryset.filter(Q(user=user) | Q(mention_users=user))
activities = get_activity_feed( activities = get_activity_feed(
request.user, 'direct', queryset=queryset) request.user, privacy=['direct'], queryset=queryset)
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page) activity_page = paginated.page(page)
@ -91,7 +89,7 @@ class Status(View):
def get(self, request, username, status_id): def get(self, request, username, status_id):
''' display a particular status (and replies, etc) ''' ''' display a particular status (and replies, etc) '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
status = models.Status.objects.select_subclasses().get( status = models.Status.objects.select_subclasses().get(
id=status_id, deleted=False) id=status_id, deleted=False)
except ValueError: except ValueError:
@ -107,7 +105,7 @@ class Status(View):
if is_api_request(request): if is_api_request(request):
return ActivitypubResponse( return ActivitypubResponse(
status.to_activity(pure=not is_bookworm_request(request))) status.to_activity(pure=not is_bookwyrm_request(request)))
data = {**feed_page_data(request.user), **{ data = {**feed_page_data(request.user), **{
'title': 'Status by %s' % user.username, 'title': 'Status by %s' % user.username,

View file

@ -1,5 +1,6 @@
''' views for actions you can take in the application ''' ''' views for actions you can take in the application '''
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -13,17 +14,18 @@ def follow(request):
''' follow another user, here or abroad ''' ''' follow another user, here or abroad '''
username = request.POST['user'] username = request.POST['user']
try: try:
to_follow = get_user_from_username(username) to_follow = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseBadRequest() return HttpResponseBadRequest()
rel, _ = models.UserFollowRequest.objects.get_or_create( try:
user_subject=request.user, models.UserFollowRequest.objects.create(
user_object=to_follow, 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) return redirect(to_follow.local_path)
@ -33,16 +35,14 @@ def unfollow(request):
''' unfollow a user ''' ''' unfollow a user '''
username = request.POST['user'] username = request.POST['user']
try: try:
to_unfollow = get_user_from_username(username) to_unfollow = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseBadRequest() return HttpResponseBadRequest()
models.UserFollows.objects.get( models.UserFollows.objects.get(
user_subject=request.user, user_subject=request.user,
user_object=to_unfollow user_object=to_unfollow
) ).delete()
to_unfollow.followers.remove(request.user)
return redirect(to_unfollow.local_path) return redirect(to_unfollow.local_path)
@ -52,7 +52,7 @@ def accept_follow_request(request):
''' a user accepts a follow request ''' ''' a user accepts a follow request '''
username = request.POST['user'] username = request.POST['user']
try: try:
requester = get_user_from_username(username) requester = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -75,7 +75,7 @@ def delete_follow_request(request):
''' a user rejects a follow request ''' ''' a user rejects a follow request '''
username = request.POST['user'] username = request.POST['user']
try: try:
requester = get_user_from_username(username) requester = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseBadRequest() return HttpResponseBadRequest()

View file

@ -18,7 +18,7 @@ class Goal(View):
''' track books for the year ''' ''' track books for the year '''
def get(self, request, username, year): def get(self, request, username, year):
''' reading goal page ''' ''' reading goal page '''
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
year = int(year) year = int(year)
goal = models.AnnualGoal.objects.filter( goal = models.AnnualGoal.objects.filter(
year=year, user=user year=year, user=user
@ -42,7 +42,7 @@ class Goal(View):
def post(self, request, username, year): def post(self, request, username, year):
''' update or create an annual goal ''' ''' update or create an annual goal '''
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
if user != request.user: if user != request.user:
return HttpResponseNotFound() return HttpResponseNotFound()

View file

@ -1,6 +1,7 @@
''' helper functions used in various views ''' ''' helper functions used in various views '''
import re import re
from requests import HTTPError from requests import HTTPError
from django.core.exceptions import FieldError
from django.db.models import Q from django.db.models import Q
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
@ -9,13 +10,13 @@ from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex from bookwyrm.utils import regex
def get_user_from_username(username): def get_user_from_username(viewer, username):
''' helper function to resolve a localname or a username to a user ''' ''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found # raises DoesNotExist if user is now found
try: try:
return models.User.objects.get(localname=username) return models.User.viewer_aware_objects(viewer).get(localname=username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return models.User.objects.get(username=username) return models.User.viewer_aware_objects(viewer).get(username=username)
def is_api_request(request): def is_api_request(request):
@ -24,8 +25,8 @@ def is_api_request(request):
request.path[-5:] == '.json' request.path[-5:] == '.json'
def is_bookworm_request(request): def is_bookwyrm_request(request):
''' check if the request is coming from another bookworm instance ''' ''' check if the request is coming from another bookwyrm instance '''
user_agent = request.headers.get('User-Agent') user_agent = request.headers.get('User-Agent')
if user_agent is None or \ if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None: re.search(regex.bookwyrm_user_agent, user_agent) is None:
@ -59,8 +60,11 @@ def object_visible_to_user(viewer, obj):
return False return False
def privacy_filter(viewer, queryset, privacy_levels, following_only=False): def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False):
''' filter objects that have "user" and "privacy" fields ''' ''' filter objects that have "user" and "privacy" fields '''
privacy_levels = privacy_levels or \
['public', 'unlisted', 'followers', 'direct']
# exclude blocks from both directions # exclude blocks from both directions
if not viewer.is_anonymous: if not viewer.is_anonymous:
blocked = models.User.objects.filter(id__in=viewer.blocks.all()).all() blocked = models.User.objects.filter(id__in=viewer.blocks.all()).all()
@ -95,30 +99,56 @@ def privacy_filter(viewer, queryset, privacy_levels, following_only=False):
# exclude direct messages not intended for the user # exclude direct messages not intended for the user
if 'direct' in privacy_levels: if 'direct' in privacy_levels:
queryset = queryset.exclude( try:
~Q( queryset = queryset.exclude(
Q(user=viewer) | Q(mention_users=viewer) ~Q(
), privacy='direct' Q(user=viewer) | Q(mention_users=viewer)
) ), privacy='direct'
)
except FieldError:
queryset = queryset.exclude(
~Q(user=viewer), privacy='direct'
)
return queryset return queryset
def get_activity_feed( def get_activity_feed(
user, privacy, local_only=False, following_only=False, user, privacy=None, local_only=False, following_only=False,
queryset=models.Status.objects): queryset=None):
''' get a filtered queryset of statuses ''' ''' get a filtered queryset of statuses '''
# if we're looking at Status, we need this. We don't if it's Comment if queryset is None:
if hasattr(queryset, 'select_subclasses'): queryset = models.Status.objects.select_subclasses()
queryset = queryset.select_subclasses()
# exclude deleted # exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date') queryset = queryset.exclude(deleted=True).order_by('-published_date')
# apply privacy filters # apply privacy filters
privacy = privacy if isinstance(privacy, list) else [privacy]
queryset = privacy_filter( queryset = privacy_filter(
user, queryset, privacy, following_only=following_only) user, queryset, privacy, following_only=following_only)
# only show dms if we only want dms
if privacy == ['direct']:
# dms are direct statuses not related to books
queryset = queryset.filter(
review__isnull=True,
comment__isnull=True,
quotation__isnull=True,
generatednote__isnull=True,
)
else:
try:
queryset = queryset.exclude(
review__isnull=True,
comment__isnull=True,
quotation__isnull=True,
generatednote__isnull=True,
privacy='direct'
)
except FieldError:
# if we're looking at a subtype of Status (like Review)
pass
# filter for only local status # filter for only local status
if local_only: if local_only:
queryset = queryset.filter(user__local=True) queryset = queryset.filter(user__local=True)
@ -162,7 +192,7 @@ def handle_remote_webfinger(query):
if link.get('rel') == 'self': if link.get('rel') == 'self':
try: try:
user = activitypub.resolve_remote_id( user = activitypub.resolve_remote_id(
models.User, link['href'] link['href'], model=models.User
) )
except KeyError: except KeyError:
return None return None

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

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

View file

@ -1,5 +1,6 @@
''' invites when registration is closed ''' ''' invites when registration is closed '''
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -7,6 +8,7 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable= no-self-use # pylint: disable= no-self-use
@ -18,10 +20,18 @@ class ManageInvites(View):
''' create invites ''' ''' create invites '''
def get(self, request): def get(self, request):
''' invite management page ''' ''' invite management page '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
paginated = Paginator(models.SiteInvite.objects.filter(
user=request.user
).order_by('-created_date'), PAGE_LENGTH)
data = { data = {
'title': 'Invitations', 'title': 'Invitations',
'invites': models.SiteInvite.objects.filter( 'invites': paginated.page(page),
user=request.user).order_by('-created_date'),
'form': forms.CreateInviteForm(), 'form': forms.CreateInviteForm(),
} }
return TemplateResponse(request, 'settings/manage_invites.html', data) return TemplateResponse(request, 'settings/manage_invites.html', data)
@ -36,7 +46,15 @@ class ManageInvites(View):
invite.user = request.user invite.user = request.user
invite.save() invite.save()
return redirect('/settings/invites') paginated = Paginator(models.SiteInvite.objects.filter(
user=request.user
).order_by('-created_date'), PAGE_LENGTH)
data = {
'title': 'Invitations',
'invites': paginated.page(1),
'form': form
}
return TemplateResponse(request, 'settings/manage_invites.html', data)
class Invite(View): class Invite(View):

View file

@ -1,11 +1,10 @@
''' non-interactive pages ''' ''' non-interactive pages '''
from django.db.models import Avg, Max from django.db.models import Max
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views import View from django.views import View
from bookwyrm import forms, models from bookwyrm import forms, models
from .feed import Feed from .feed import Feed
from .helpers import get_activity_feed
# pylint: disable= no-self-use # pylint: disable= no-self-use
@ -16,7 +15,7 @@ class About(View):
data = { data = {
'title': 'About', 'title': 'About',
} }
return TemplateResponse(request, 'about.html', data) return TemplateResponse(request, 'discover/about.html', data)
class Home(View): class Home(View):
''' discover page or home feed depending on auth ''' ''' discover page or home feed depending on auth '''
@ -34,6 +33,7 @@ class Discover(View):
''' tiled book activity page ''' ''' tiled book activity page '''
books = models.Edition.objects.filter( books = models.Edition.objects.filter(
review__published_date__isnull=False, review__published_date__isnull=False,
review__deleted=False,
review__user__local=True, review__user__local=True,
review__privacy__in=['public', 'unlisted'], review__privacy__in=['public', 'unlisted'],
).exclude( ).exclude(
@ -42,18 +42,9 @@ class Discover(View):
Max('review__published_date') Max('review__published_date')
).order_by('-review__published_date__max')[:6] ).order_by('-review__published_date__max')[:6]
ratings = {}
for book in books:
reviews = models.Review.objects.filter(
book__in=book.parent_work.editions.all()
)
reviews = get_activity_feed(
request.user, ['public', 'unlisted'], queryset=reviews)
ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg']
data = { data = {
'title': 'Discover', 'title': 'Discover',
'register_form': forms.RegisterForm(), 'register_form': forms.RegisterForm(),
'books': list(set(books)), 'books': list(set(books)),
'ratings': ratings
} }
return TemplateResponse(request, 'discover.html', data) return TemplateResponse(request, 'discover/discover.html', data)

View file

@ -35,7 +35,8 @@ class Lists(View):
).filter( ).filter(
item_count__gt=0 item_count__gt=0
).distinct().all() ).distinct().all()
lists = privacy_filter(request.user, lists, ['public', 'followers']) lists = privacy_filter(
request.user, lists, privacy_levels=['public', 'followers'])
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {
@ -65,10 +66,9 @@ class UserLists(View):
page = int(request.GET.get('page', 1)) page = int(request.GET.get('page', 1))
except ValueError: except ValueError:
page = 1 page = 1
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
lists = models.List.objects.filter(user=user).all() lists = models.List.objects.filter(user=user).all()
lists = privacy_filter( lists = privacy_filter(request.user, lists)
request.user, lists, ['public', 'followers', 'unlisted'])
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {

View file

@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404
from django.views import View from django.views import View
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
from .helpers import is_bookwyrm_request
# pylint: disable= no-self-use # pylint: disable= no-self-use
@ -17,6 +18,10 @@ class Outbox(View):
filter_type = None filter_type = None
return JsonResponse( return JsonResponse(
user.to_outbox(**request.GET, filter_type=filter_type), user.to_outbox(
**request.GET,
filter_type=filter_type,
pure=not is_bookwyrm_request(request)
),
encoder=activitypub.ActivityEncoder encoder=activitypub.ActivityEncoder
) )

View file

@ -11,7 +11,7 @@ class RssFeed(Feed):
def get_object(self, request, username): def get_object(self, request, username):
''' the user who's posts get serialized ''' ''' the user who's posts get serialized '''
return get_user_from_username(username) return get_user_from_username(request.user, username)
def link(self, obj): def link(self, obj):
@ -27,7 +27,10 @@ class RssFeed(Feed):
def items(self, obj): def items(self, obj):
''' the user's activity feed ''' ''' the user's activity feed '''
return get_activity_feed( return get_activity_feed(
obj, ['public', 'unlisted'], queryset=obj.status_set) obj,
privacy=['public', 'unlisted'],
queryset=obj.status_set.select_subclasses()
)
def item_link(self, item): def item_link(self, item):

View file

@ -33,7 +33,7 @@ class Search(View):
handle_remote_webfinger(query) handle_remote_webfinger(query)
# do a user search # do a user search
user_results = models.User.objects.annotate( user_results = models.User.viewer_aware_objects(request.user).annotate(
similarity=Greatest( similarity=Greatest(
TrigramSimilarity('username', query), TrigramSimilarity('username', query),
TrigramSimilarity('localname', query), TrigramSimilarity('localname', query),
@ -44,7 +44,8 @@ class Search(View):
# any relevent lists? # any relevent lists?
list_results = privacy_filter( list_results = privacy_filter(
request.user, models.List.objects, ['public', 'followers'] request.user, models.List.objects,
privacy_levels=['public', 'followers']
).annotate( ).annotate(
similarity=Greatest( similarity=Greatest(
TrigramSimilarity('name', query), TrigramSimilarity('name', query),

View file

@ -19,7 +19,7 @@ class Shelf(View):
def get(self, request, username, shelf_identifier): def get(self, request, username, shelf_identifier):
''' display a shelf ''' ''' display a shelf '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()

View file

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

View file

@ -26,7 +26,7 @@ class User(View):
def get(self, request, username): def get(self, request, username):
''' profile page for a user ''' ''' profile page for a user '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
@ -71,8 +71,7 @@ class User(View):
# user's posts # user's posts
activities = get_activity_feed( activities = get_activity_feed(
request.user, request.user,
['public', 'unlisted', 'followers'], queryset=user.status_set.select_subclasses(),
queryset=user.status_set
) )
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
goal = models.AnnualGoal.objects.filter( goal = models.AnnualGoal.objects.filter(
@ -96,7 +95,7 @@ class Followers(View):
def get(self, request, username): def get(self, request, username):
''' list of followers ''' ''' list of followers '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
@ -121,7 +120,7 @@ class Following(View):
def get(self, request, username): def get(self, request, username):
''' list of followers ''' ''' list of followers '''
try: try:
user = get_user_from_username(username) user = get_user_from_username(request.user, username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()

View file

@ -25,5 +25,5 @@ app.autodiscover_tasks(
['bookwyrm'], related_name='connectors.abstract_connector') ['bookwyrm'], related_name='connectors.abstract_connector')
app.autodiscover_tasks(['bookwyrm'], related_name='emailing') app.autodiscover_tasks(['bookwyrm'], related_name='emailing')
app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import') 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='models.user')
app.autodiscover_tasks(['bookwyrm'], related_name='views.inbox')