diff --git a/README.md b/README.md index 6a624ab3..c8b6e92b 100644 --- a/README.md +++ b/README.md @@ -112,36 +112,42 @@ Once the build is complete, you can access the instance at `localhost:1333` ## Installing in Production This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production. + ### Server setup - Get a domain name and set up DNS for your server - Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04) - - Set 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 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: `git clone git@github.com:mouse-reeve/bookwyrm.git` - Switch to the `production` branch `git checkout production` - Create your environment variables file `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 database password for postgres - Update your nginx configuration in `nginx/default.conf` - Replace `your-domain.com` with your domain name - - Run the application (this should also set up a Certbot ssl cert for your domain) - `docker-compose up --build` - Make sure all the images build successfully + - Run the application (this should also set up a Certbot ssl cert for your domain) with + `docker-compose up --build`, and make sure all the images build successfully - When docker has built successfully, stop the process with `CTRL-C` - Comment out the `command: certonly...` line in `docker-compose.yml` - - Run docker-compose in the background - `docker-compose up -d` - - Initialize the database - `./bw-dev initdb` - - Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe locationgi - - Congrats! You did it, go to your domain and enjoy the fruits of your labors + - Run docker-compose in the background with: `docker-compose up -d` + - Initialize the database with: `./bw-dev initdb` + - Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe location + +Congrats! You did it, go to your domain and enjoy the fruits of your labors. + ### 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) - On your server, open the django shell `./bw-dev shell` diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 3a90ac0f..2be8cf19 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -2,14 +2,13 @@ import inspect import sys -from .base_activity import ActivityEncoder, Signature +from .base_activity import ActivityEncoder, Signature, naive_parse from .base_activity import Link, Mention from .base_activity import ActivitySerializerError, resolve_remote_id from .image import Image from .note import Note, GeneratedNote, Article, Comment, Quotation from .note import Review, Rating from .note import Tombstone -from .interaction import Boost, Like from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import BookList, Shelf from .person import Person, PublicKey @@ -17,10 +16,15 @@ from .response import ActivitypubResponse from .book import Edition, Work, Author from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject, Block -from .verbs import Add, AddBook, AddListItem, Remove +from .verbs import Add, Remove +from .verbs import Announce, Like # this creates a list of all the Activity types that we can serialize, # so when an Activity comes in from outside, we can check if it's known cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_objects = {c[0]: c[1] for c in cls_members \ if hasattr(c[1], 'to_model')} + +def parse(activity_json): + ''' figure out what activity this is and parse it ''' + return naive_parse(activity_objects, activity_json) diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 5f35f1d7..57f1a713 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -40,6 +40,20 @@ class Signature: signatureValue: str type: str = 'RsaSignature2017' +def naive_parse(activity_objects, activity_json, serializer=None): + ''' this navigates circular import issues ''' + if not serializer: + if activity_json.get('publicKeyPem'): + # ugh + activity_json['type'] = 'PublicKey' + try: + activity_type = activity_json['type'] + serializer = activity_objects[activity_type] + except KeyError as e: + raise ActivitySerializerError(e) + + return serializer(activity_objects=activity_objects, **activity_json) + @dataclass(init=False) class ActivityObject: @@ -47,13 +61,30 @@ class ActivityObject: id: str type: str - def __init__(self, **kwargs): + def __init__(self, activity_objects=None, **kwargs): ''' this lets you pass in an object with fields that aren't in the dataclass, which it ignores. Any field in the dataclass is required or has a default value ''' for field in fields(self): try: value = kwargs[field.name] + if value in (None, MISSING): + raise KeyError() + try: + is_subclass = issubclass(field.type, ActivityObject) + except TypeError: + is_subclass = False + # serialize a model obj + if hasattr(value, 'to_activity'): + value = value.to_activity() + # parse a dict into the appropriate activity + elif is_subclass and isinstance(value, dict): + if activity_objects: + value = naive_parse(activity_objects, value) + else: + value = naive_parse( + activity_objects, value, serializer=field.type) + except KeyError: if field.default == MISSING and \ field.default_factory == MISSING: @@ -63,31 +94,29 @@ class ActivityObject: setattr(self, field.name, value) - def to_model(self, model, instance=None, save=True): + def to_model(self, model=None, instance=None, allow_create=True, save=True): ''' convert from an activity to a model instance ''' - if self.type != model.activity_serializer.type: - raise ActivitySerializerError( - 'Wrong activity type "%s" for activity of type "%s"' % \ - (model.activity_serializer.type, - self.type) - ) + model = model or get_model_from_type(self.type) - if not isinstance(self, model.activity_serializer): - raise ActivitySerializerError( - 'Wrong activity type "%s" for model "%s" (expects "%s")' % \ - (self.__class__, - model.__name__, - model.activity_serializer) - ) + # only reject statuses if we're potentially creating them + if allow_create and \ + hasattr(model, 'ignore_activity') and \ + model.ignore_activity(self): + return None - if hasattr(model, 'ignore_activity') and model.ignore_activity(self): - return instance + # check for an existing instance + instance = instance or model.find_existing(self.serialize()) - # check for an existing instance, if we're not updating a known obj - instance = instance or model.find_existing(self.serialize()) or model() + if not instance and not allow_create: + # so that we don't create when we want to delete or update + return None + instance = instance or model() for field in instance.simple_fields: - field.set_field_from_activity(instance, self) + try: + field.set_field_from_activity(instance, self) + except AttributeError as e: + raise ActivitySerializerError(e) # image fields have to be set after other fields because they can save # too early and jank up users @@ -139,7 +168,14 @@ class ActivityObject: def serialize(self): ''' convert to dictionary with context attr ''' - data = self.__dict__ + data = self.__dict__.copy() + # recursively serialize + for (k, v) in data.items(): + try: + if issubclass(type(v), ActivityObject): + data[k] = v.serialize() + except TypeError: + pass data = {k:v for (k, v) in data.items() if v is not None} data['@context'] = 'https://www.w3.org/ns/activitystreams' return data @@ -182,7 +218,7 @@ def set_related_field( getattr(model_field, 'activitypub_field'), instance.remote_id ) - item = activity.to_model(model) + item = activity.to_model() # if the related field isn't serialized (attachments on Status), then # we have to set it post-creation @@ -191,11 +227,24 @@ def set_related_field( item.save() -def resolve_remote_id(model, remote_id, refresh=False, save=True): +def get_model_from_type(activity_type): + ''' given the activity, what type of model ''' + models = apps.get_models() + model = [m for m in models if hasattr(m, 'activity_serializer') and \ + hasattr(m.activity_serializer, 'type') and \ + m.activity_serializer.type == activity_type] + if not model: + raise ActivitySerializerError( + 'No model found for activity type "%s"' % activity_type) + return model[0] + + +def resolve_remote_id(remote_id, model=None, refresh=False, save=True): ''' take a remote_id and return an instance, creating if necessary ''' - result = model.find_existing_by_remote_id(remote_id) - if result and not refresh: - return result + if model:# a bonus check we can do if we already know the model + result = model.find_existing_by_remote_id(remote_id) + if result and not refresh: + return result # load the data and create the object try: @@ -204,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True): raise ActivitySerializerError( 'Could not connect to host for remote_id in %s model: %s' % \ (model.__name__, remote_id)) + # determine the model implicitly, if not provided + if not model: + model = get_model_from_type(data.get('type')) # check for existing items with shared unique identifiers - if not result: - result = model.find_existing(data) - if result and not refresh: - return result + result = model.find_existing(data) + if result and not refresh: + return result item = model.activity_serializer(**data) # if we're refreshing, "result" will be set and we'll update it - return item.to_model(model, instance=result, save=save) + return item.to_model(model=model, instance=result, save=save) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 68036559..87c40c90 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -67,4 +67,4 @@ class Author(ActivityObject): librarythingKey: str = '' goodreadsKey: str = '' wikipediaLink: str = '' - type: str = 'Person' + type: str = 'Author' diff --git a/bookwyrm/activitypub/interaction.py b/bookwyrm/activitypub/interaction.py deleted file mode 100644 index 752b2fe3..00000000 --- a/bookwyrm/activitypub/interaction.py +++ /dev/null @@ -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' diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index ad08e324..54c69ce4 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -1,6 +1,7 @@ ''' note serializer and children thereof ''' from dataclasses import dataclass, field from typing import Dict, List +from django.apps import apps from .base_activity import ActivityObject, Link from .image import Image @@ -8,10 +9,13 @@ from .image import Image @dataclass(init=False) class Tombstone(ActivityObject): ''' the placeholder for a deleted status ''' - published: str - deleted: str type: str = 'Tombstone' + def to_model(self, *args, **kwargs): + ''' this should never really get serialized, just searched for ''' + model = apps.get_model('bookwyrm.Status') + return model.find_existing_by_remote_id(self.id) + @dataclass(init=False) class Note(ActivityObject): diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index cf642994..14b35f3c 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -17,6 +17,7 @@ class OrderedCollection(ActivityObject): @dataclass(init=False) class OrderedCollectionPrivate(OrderedCollection): + ''' an ordered collection with privacy settings ''' to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) @@ -38,6 +39,6 @@ class OrderedCollectionPage(ActivityObject): ''' structure of an ordered collection activity ''' partOf: str orderedItems: List - next: str - prev: str + next: str = None + prev: str = None type: str = 'OrderedCollectionPage' diff --git a/bookwyrm/activitypub/response.py b/bookwyrm/activitypub/response.py index bbc44c4d..8f3c050b 100644 --- a/bookwyrm/activitypub/response.py +++ b/bookwyrm/activitypub/response.py @@ -9,7 +9,7 @@ class ActivitypubResponse(JsonResponse): configures some stuff beforehand. Made to be a drop-in replacement of JsonResponse. """ - def __init__(self, data, encoder=ActivityEncoder, safe=True, + def __init__(self, data, encoder=ActivityEncoder, safe=False, json_dumps_params=None, **kwargs): if 'content_type' not in kwargs: diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 190cd739..1236338b 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -1,10 +1,12 @@ ''' undo wrapper activity ''' from dataclasses import dataclass from typing import List +from django.apps import apps -from .base_activity import ActivityObject, Signature +from .base_activity import ActivityObject, Signature, resolve_remote_id from .book import Edition + @dataclass(init=False) class Verb(ActivityObject): ''' generic fields for activities - maybe an unecessary level of @@ -12,6 +14,10 @@ class Verb(ActivityObject): actor: str object: ActivityObject + def action(self): + ''' usually we just want to save, this can be overridden as needed ''' + self.object.to_model() + @dataclass(init=False) class Create(Verb): @@ -29,6 +35,12 @@ class Delete(Verb): cc: List type: str = 'Delete' + def action(self): + ''' find and delete the activity object ''' + obj = self.object.to_model(save=False, allow_create=False) + obj.delete() + + @dataclass(init=False) class Update(Verb): @@ -36,29 +48,60 @@ class Update(Verb): to: List type: str = 'Update' + def action(self): + ''' update a model instance from the dataclass ''' + self.object.to_model(allow_create=False) + @dataclass(init=False) class Undo(Verb): ''' Undo an activity ''' type: str = 'Undo' + def action(self): + ''' find and remove the activity object ''' + # this is so hacky but it does make it work.... + # (because you Reject a request and Undo a follow + model = None + if self.object.type == 'Follow': + model = apps.get_model('bookwyrm.UserFollows') + obj = self.object.to_model(model=model, save=False, allow_create=False) + obj.delete() + @dataclass(init=False) class Follow(Verb): ''' Follow activity ''' + object: str type: str = 'Follow' + def action(self): + ''' relationship save ''' + self.to_model() + + @dataclass(init=False) class Block(Verb): ''' Block activity ''' + object: str type: str = 'Block' + def action(self): + ''' relationship save ''' + self.to_model() + + @dataclass(init=False) class Accept(Verb): ''' Accept activity ''' object: Follow type: str = 'Accept' + def action(self): + ''' find and remove the activity object ''' + obj = self.object.to_model(save=False, allow_create=False) + obj.accept() + @dataclass(init=False) class Reject(Verb): @@ -66,32 +109,60 @@ class Reject(Verb): object: Follow type: str = 'Reject' + def action(self): + ''' find and remove the activity object ''' + obj = self.object.to_model(save=False, allow_create=False) + obj.reject() + @dataclass(init=False) class Add(Verb): '''Add activity ''' target: str - object: ActivityObject - type: str = 'Add' - - -@dataclass(init=False) -class AddBook(Add): - '''Add activity that's aware of the book obj ''' object: Edition type: str = 'Add' - - -@dataclass(init=False) -class AddListItem(AddBook): - '''Add activity that's aware of the book obj ''' notes: str = None order: int = 0 approved: bool = True + def action(self): + ''' add obj to collection ''' + target = resolve_remote_id(self.target, refresh=False) + # we want to related field that isn't the book, this is janky af sorry + model = [t for t in type(target)._meta.related_objects \ + if t.name != 'edition'][0].related_model + self.to_model(model=model) + @dataclass(init=False) class Remove(Verb): '''Remove activity ''' target: ActivityObject type: str = 'Remove' + + def action(self): + ''' find and remove the activity object ''' + obj = self.object.to_model(save=False, allow_create=False) + obj.delete() + + +@dataclass(init=False) +class Like(Verb): + ''' a user faving an object ''' + object: str + type: str = 'Like' + + def action(self): + ''' like ''' + self.to_model() + + +@dataclass(init=False) +class Announce(Verb): + ''' boosting a status ''' + object: str + type: str = 'Announce' + + def action(self): + ''' boost ''' + self.to_model() diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 527d2f42..68ff2a48 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -127,7 +127,7 @@ class AbstractConnector(AbstractMinimalConnector): # create activitypub object work_activity = activitypub.Work(**work_data) # this will dedupe automatically - work = work_activity.to_model(models.Work) + work = work_activity.to_model(model=models.Work) for author in self.get_authors_from_data(data): work.authors.add(author) @@ -141,7 +141,7 @@ class AbstractConnector(AbstractMinimalConnector): mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data['work'] = work.remote_id edition_activity = activitypub.Edition(**mapped_data) - edition = edition_activity.to_model(models.Edition) + edition = edition_activity.to_model(model=models.Edition) edition.connector = self.connector edition.save() @@ -168,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector): mapped_data = dict_from_mappings(data, self.author_mappings) activity = activitypub.Author(**mapped_data) # this will dedupe - return activity.to_model(models.Author) + return activity.to_model(model=models.Author) @abstractmethod @@ -216,11 +216,7 @@ def get_data(url): raise ConnectorException() if not resp.ok: - try: - resp.raise_for_status() - except requests.exceptions.HTTPError as e: - logger.exception(e) - raise ConnectorException() + raise ConnectorException() try: data = resp.json() except ValueError as e: diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 1f877993..00e6c62f 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -7,7 +7,7 @@ class Connector(AbstractMinimalConnector): ''' this is basically just for search ''' def get_or_create_book(self, remote_id): - edition = activitypub.resolve_remote_id(models.Edition, remote_id) + edition = activitypub.resolve_remote_id(remote_id, model=models.Edition) work = edition.parent_work work.default_edition = work.get_default_edition() work.save() diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py deleted file mode 100644 index 18db1069..00000000 --- a/bookwyrm/incoming.py +++ /dev/null @@ -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) diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 84293725..bebe00d0 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -6,6 +6,7 @@ import operator import logging from uuid import uuid4 import requests +from requests.exceptions import HTTPError, SSLError from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 @@ -156,10 +157,14 @@ class ActivitypubMixin: return recipients - def to_activity(self): + def to_activity_dataclass(self): ''' convert from a model to an activity ''' activity = generate_activity(self) - return self.activity_serializer(**activity).serialize() + return self.activity_serializer(**activity) + + def to_activity(self, **kwargs): # pylint: disable=unused-argument + ''' convert from a model to a json activity ''' + return self.to_activity_dataclass().serialize() class ObjectMixin(ActivitypubMixin): @@ -187,7 +192,7 @@ class ObjectMixin(ActivitypubMixin): try: software = None - # do we have a "pure" activitypub version of this for mastodon? + # do we have a "pure" activitypub version of this for mastodon? if hasattr(self, 'pure_content'): pure_activity = self.to_create_activity(user, pure=True) self.broadcast(pure_activity, user, software='other') @@ -195,7 +200,7 @@ class ObjectMixin(ActivitypubMixin): # sends to BW only if we just did a pure version for masto activity = self.to_create_activity(user) self.broadcast(activity, user, software=software) - except KeyError: + except AttributeError: # janky as heck, this catches the mutliple inheritence chain # for boosts and ignores this auxilliary broadcast return @@ -224,26 +229,26 @@ class ObjectMixin(ActivitypubMixin): def to_create_activity(self, user, **kwargs): ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(**kwargs) + activity_object = self.to_activity_dataclass(**kwargs) signature = None create_id = self.remote_id + '/activity' - if 'content' in activity_object and activity_object['content']: + if hasattr(activity_object, 'content') and activity_object.content: signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) - content = activity_object['content'] + content = activity_object.content signed_message = signer.sign(SHA256.new(content.encode('utf8'))) signature = activitypub.Signature( creator='%s#main-key' % user.remote_id, - created=activity_object['published'], + created=activity_object.published, signatureValue=b64encode(signed_message).decode('utf8') ) return activitypub.Create( id=create_id, actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], + to=activity_object.to, + cc=activity_object.cc, object=activity_object, signature=signature, ).serialize() @@ -256,7 +261,7 @@ class ObjectMixin(ActivitypubMixin): actor=user.remote_id, to=['%s/followers' % user.remote_id], cc=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity(), + object=self, ).serialize() @@ -267,7 +272,7 @@ class ObjectMixin(ActivitypubMixin): id=activity_id, actor=user.remote_id, to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() + object=self ).serialize() @@ -308,7 +313,7 @@ class OrderedCollectionPageMixin(ObjectMixin): activity['first'] = '%s?page=1' % remote_id activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) - return serializer(**activity).serialize() + return serializer(**activity) class OrderedCollectionMixin(OrderedCollectionPageMixin): @@ -320,9 +325,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): activity_serializer = activitypub.OrderedCollection + def to_activity_dataclass(self, **kwargs): + return self.to_ordered_collection(self.collection_queryset, **kwargs) + def to_activity(self, **kwargs): ''' an ordered collection of the specified model queryset ''' - return self.to_ordered_collection(self.collection_queryset, **kwargs) + return self.to_ordered_collection( + self.collection_queryset, **kwargs).serialize() class CollectionItemMixin(ActivitypubMixin): @@ -359,7 +368,7 @@ class CollectionItemMixin(ActivitypubMixin): return activitypub.Add( id='%s#add' % self.remote_id, actor=self.user.remote_id, - object=object_field.to_activity(), + object=object_field, target=collection_field.remote_id ).serialize() @@ -370,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin): return activitypub.Remove( id='%s#remove' % self.remote_id, actor=self.user.remote_id, - object=object_field.to_activity(), + object=object_field, target=collection_field.remote_id ).serialize() @@ -399,7 +408,7 @@ class ActivityMixin(ActivitypubMixin): return activitypub.Undo( id='%s#undo' % self.remote_id, actor=user.remote_id, - object=self.to_activity() + object=self, ).serialize() @@ -440,7 +449,7 @@ def broadcast_task(sender_id, activity, recipients): for recipient in recipients: try: sign_and_send(sender, activity, recipient) - except requests.exceptions.HTTPError as e: + except (HTTPError, SSLError) as e: logger.exception(e) @@ -472,7 +481,7 @@ def sign_and_send(sender, data, destination): # pylint: disable=unused-argument def to_ordered_collection_page( - queryset, remote_id, id_only=False, page=1, **kwargs): + queryset, remote_id, id_only=False, page=1, pure=False, **kwargs): ''' serialize and pagiante a queryset ''' paginated = Paginator(queryset, PAGE_LENGTH) @@ -480,7 +489,7 @@ def to_ordered_collection_page( if id_only: items = [s.remote_id for s in activity_page.object_list] else: - items = [s.to_activity() for s in activity_page.object_list] + items = [s.to_activity(pure=pure) for s in activity_page.object_list] prev_page = next_page = None if activity_page.has_next(): @@ -494,4 +503,4 @@ def to_ordered_collection_page( orderedItems=items, next=next_page, prev=prev_page - ).serialize() + ) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 55de1fab..4ea527eb 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -122,13 +122,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): return None related_model = self.related_model - if isinstance(value, dict) and value.get('id'): + if hasattr(value, 'id') and value.id: if not self.load_remote: # only look in the local database - return related_model.find_existing(value) + return related_model.find_existing(value.serialize()) # this is an activitypub object, which we can deserialize - activity_serializer = related_model.activity_serializer - return activity_serializer(**value).to_model(related_model) + return value.to_model(model=related_model) try: # make sure the value looks like a remote id validate_remote_id(value) @@ -139,7 +138,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): if not self.load_remote: # only look in the local database return related_model.find_existing_by_remote_id(value) - return activitypub.resolve_remote_id(related_model, value) + return activitypub.resolve_remote_id(value, model=related_model) class RemoteIdField(ActivitypubFieldMixin, models.CharField): @@ -280,7 +279,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): except ValidationError: continue items.append( - activitypub.resolve_remote_id(self.related_model, remote_id) + activitypub.resolve_remote_id( + remote_id, model=self.related_model) ) return items @@ -317,7 +317,8 @@ class TagField(ManyToManyField): # tags can contain multiple types continue items.append( - activitypub.resolve_remote_id(self.related_model, link.href) + activitypub.resolve_remote_id( + link.href, model=self.related_model) ) return items @@ -366,8 +367,8 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): image_slug = value # when it's an inline image (User avatar/icon, Book cover), it's a json # blob, but when it's an attached image, it's just a url - if isinstance(image_slug, dict): - url = image_slug.get('url') + if hasattr(image_slug, 'url'): + url = image_slug.url elif isinstance(image_slug, str): url = image_slug else: diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index ef48ed95..1b14c2aa 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -68,7 +68,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): order = fields.IntegerField(blank=True, null=True) endorsement = models.ManyToManyField('User', related_name='endorsers') - activity_serializer = activitypub.AddListItem + activity_serializer = activitypub.Add object_field = 'book' collection_field = 'book_list' diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index e2db5468..3b0e85d4 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,11 +1,11 @@ ''' defines relationships between users ''' from django.apps import apps -from django.db import models, transaction +from django.db import models, transaction, IntegrityError from django.db.models import Q -from django.dispatch import receiver from bookwyrm import activitypub from .activitypub_mixin import ActivitypubMixin, ActivityMixin +from .activitypub_mixin import generate_activity from .base_model import BookWyrmModel from . import fields @@ -56,11 +56,30 @@ class UserRelationship(BookWyrmModel): return '%s#%s/%d' % (base_path, status, self.id) -class UserFollows(ActivitypubMixin, UserRelationship): +class UserFollows(ActivityMixin, UserRelationship): ''' Following a user ''' status = 'follows' - activity_serializer = activitypub.Follow + def to_activity(self): + ''' overrides default to manually set serializer ''' + return activitypub.Follow(**generate_activity(self)) + + def save(self, *args, **kwargs): + ''' really really don't let a user follow someone who blocked them ''' + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.user_subject, + user_object=self.user_object, + ) | Q( + user_subject=self.user_object, + user_object=self.user_subject, + ) + ).exists(): + raise IntegrityError() + # don't broadcast this type of relationship -- accepts and requests + # are handled by the UserFollowRequest model + super().save(*args, broadcast=False, **kwargs) @classmethod def from_request(cls, follow_request): @@ -79,31 +98,36 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): def save(self, *args, broadcast=True, **kwargs): ''' make sure the follow or block relationship doesn't already exist ''' - try: - UserFollows.objects.get( + # don't create a request if a follow already exists + if UserFollows.objects.filter( user_subject=self.user_subject, user_object=self.user_object, - ) - # blocking in either direction is a no-go - UserBlocks.objects.get( - user_subject=self.user_subject, - user_object=self.user_object, - ) - UserBlocks.objects.get( - user_subject=self.user_object, - user_object=self.user_subject, - ) - return None - except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist): - super().save(*args, **kwargs) + ).exists(): + raise IntegrityError() + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.user_subject, + user_object=self.user_object, + ) | Q( + user_subject=self.user_object, + user_object=self.user_subject, + ) + ).exists(): + raise IntegrityError() + super().save(*args, **kwargs) if broadcast and self.user_subject.local and not self.user_object.local: self.broadcast(self.to_activity(), self.user_subject) if self.user_object.local: + manually_approves = self.user_object.manually_approves_followers + if not manually_approves: + self.accept() + model = apps.get_model('bookwyrm.Notification', require_ready=True) - notification_type = 'FOLLOW_REQUEST' \ - if self.user_object.manually_approves_followers else 'FOLLOW' + notification_type = 'FOLLOW_REQUEST' if \ + manually_approves else 'FOLLOW' model.objects.create( user=self.user_object, related_user=self.user_subject, @@ -114,28 +138,30 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): def accept(self): ''' turn this request into the real deal''' user = self.user_object - activity = activitypub.Accept( - id=self.get_remote_id(status='accepts'), - actor=self.user_object.remote_id, - object=self.to_activity() - ).serialize() + if not self.user_subject.local: + activity = activitypub.Accept( + id=self.get_remote_id(status='accepts'), + actor=self.user_object.remote_id, + object=self.to_activity() + ).serialize() + self.broadcast(activity, user) with transaction.atomic(): UserFollows.from_request(self) self.delete() - self.broadcast(activity, user) def reject(self): ''' generate a Reject for this follow request ''' - user = self.user_object - activity = activitypub.Reject( - id=self.get_remote_id(status='rejects'), - actor=self.user_object.remote_id, - object=self.to_activity() - ).serialize() + if self.user_object.local: + activity = activitypub.Reject( + id=self.get_remote_id(status='rejects'), + actor=self.user_object.remote_id, + object=self.to_activity() + ).serialize() + self.broadcast(activity, self.user_object) + self.delete() - self.broadcast(activity, user) class UserBlocks(ActivityMixin, UserRelationship): @@ -143,20 +169,15 @@ class UserBlocks(ActivityMixin, UserRelationship): status = 'blocks' activity_serializer = activitypub.Block + def save(self, *args, **kwargs): + ''' remove follow or follow request rels after a block is created ''' + super().save(*args, **kwargs) -@receiver(models.signals.post_save, sender=UserBlocks) -#pylint: disable=unused-argument -def execute_after_save(sender, instance, created, *args, **kwargs): - ''' remove follow or follow request rels after a block is created ''' - UserFollows.objects.filter( - Q(user_subject=instance.user_subject, - user_object=instance.user_object) | \ - Q(user_subject=instance.user_object, - user_object=instance.user_subject) - ).delete() - UserFollowRequest.objects.filter( - Q(user_subject=instance.user_subject, - user_object=instance.user_object) | \ - Q(user_subject=instance.user_object, - user_object=instance.user_subject) - ).delete() + UserFollows.objects.filter( + Q(user_subject=self.user_subject, user_object=self.user_object) | \ + Q(user_subject=self.user_object, user_object=self.user_subject) + ).delete() + UserFollowRequest.objects.filter( + Q(user_subject=self.user_subject, user_object=self.user_object) | \ + Q(user_subject=self.user_object, user_object=self.user_subject) + ).delete() diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 921b8617..dfb8b9b3 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -57,7 +57,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor') - activity_serializer = activitypub.AddBook + activity_serializer = activitypub.Add object_field = 'book' collection_field = 'shelf' diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 4dc0d527..16b32891 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -84,6 +84,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): related_status=self, ) + def delete(self, *args, **kwargs):#pylint: disable=unused-argument + ''' "delete" a status ''' + if hasattr(self, 'boosted_status'): + # okay but if it's a boost really delete it + super().delete(*args, **kwargs) + return + self.deleted = True + self.deleted_date = timezone.now() + self.save() + @property def recipients(self): ''' tagged users who definitely need to get this status in broadcast ''' @@ -96,6 +106,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): @classmethod def ignore_activity(cls, activity): ''' keep notes if they are replies to existing statuses ''' + if activity.type == 'Announce': + # keep it if the booster or the boosted are local + boosted = activitypub.resolve_remote_id(activity.object, save=False) + return cls.ignore_activity(boosted.to_activity_dataclass()) + + # keep if it if it's a custom type if activity.type != 'Note': return False if cls.objects.filter( @@ -106,8 +122,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): if activity.tag == MISSING or activity.tag is None: return True tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] + user_model = apps.get_model('bookwyrm.User', require_ready=True) for tag in tags: - user_model = apps.get_model('bookwyrm.User', require_ready=True) if user_model.objects.filter( remote_id=tag, local=True).exists(): # we found a mention of a known use boost @@ -139,9 +155,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): remote_id='%s/replies' % self.remote_id, collection_only=True, **kwargs - ) + ).serialize() - def to_activity(self, pure=False):# pylint: disable=arguments-differ + def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ ''' return tombstone if the status is deleted ''' if self.deleted: return activitypub.Tombstone( @@ -149,25 +165,29 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): url=self.remote_id, deleted=self.deleted_date.isoformat(), published=self.deleted_date.isoformat() - ).serialize() - activity = ActivitypubMixin.to_activity(self) - activity['replies'] = self.to_replies() + ) + activity = ActivitypubMixin.to_activity_dataclass(self) + activity.replies = self.to_replies() # "pure" serialization for non-bookwyrm instances if pure and hasattr(self, 'pure_content'): - activity['content'] = self.pure_content - if 'name' in activity: - activity['name'] = self.pure_name - activity['type'] = self.pure_type - activity['attachment'] = [ + activity.content = self.pure_content + if hasattr(activity, 'name'): + activity.name = self.pure_name + activity.type = self.pure_type + activity.attachment = [ image_serializer(b.cover, b.alt_text) \ for b in self.mention_books.all()[:4] if b.cover] if hasattr(self, 'book') and self.book.cover: - activity['attachment'].append( + activity.attachment.append( image_serializer(self.book.cover, self.book.alt_text) ) return activity + def to_activity(self, pure=False):# pylint: disable=arguments-differ + ''' json serialized activitypub class ''' + return self.to_activity_dataclass(pure=pure).serialize() + class GeneratedNote(Status): ''' these are app-generated messages about user activity ''' @@ -282,7 +302,7 @@ class Boost(ActivityMixin, Status): related_name='boosters', activitypub_field='object', ) - activity_serializer = activitypub.Boost + activity_serializer = activitypub.Announce def save(self, *args, **kwargs): ''' save and notify ''' diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py index d75f6e05..83359170 100644 --- a/bookwyrm/models/tag.py +++ b/bookwyrm/models/tag.py @@ -1,6 +1,7 @@ ''' models for storing different kinds of Activities ''' import urllib.parse +from django.apps import apps from django.db import models from bookwyrm import activitypub @@ -15,17 +16,15 @@ class Tag(OrderedCollectionMixin, BookWyrmModel): name = fields.CharField(max_length=100, unique=True) identifier = models.CharField(max_length=100) - @classmethod - def book_queryset(cls, identifier): - ''' county of books associated with this tag ''' - return cls.objects.filter( - identifier=identifier - ).order_by('-updated_date') - @property - def collection_queryset(self): - ''' books associated with this tag ''' - return self.book_queryset(self.identifier) + def books(self): + ''' count of books associated with this tag ''' + edition_model = apps.get_model('bookwyrm.Edition', require_ready=True) + return edition_model.objects.filter( + usertag__tag__identifier=self.identifier + ).order_by('-created_date').distinct() + + collection_queryset = books def get_remote_id(self): ''' tag should use identifier not id in remote_id ''' @@ -50,7 +49,7 @@ class UserTag(CollectionItemMixin, BookWyrmModel): tag = fields.ForeignKey( 'Tag', on_delete=models.PROTECT, activitypub_field='target') - activity_serializer = activitypub.AddBook + activity_serializer = activitypub.Add object_field = 'book' collection_field = 'tag' diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index da717d2e..f137236c 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -6,11 +6,10 @@ from django.apps import apps from django.contrib.auth.models import AbstractUser from django.core.validators import MinValueValidator from django.db import models -from django.dispatch import receiver from django.utils import timezone from bookwyrm import activitypub -from bookwyrm.connectors import get_data +from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review from bookwyrm.settings import DOMAIN @@ -113,6 +112,16 @@ class User(OrderedCollectionPageMixin, AbstractUser): activity_serializer = activitypub.Person + @classmethod + def viewer_aware_objects(cls, viewer): + ''' the user queryset filtered for the context of the logged in user ''' + queryset = cls.objects.filter(is_active=True) + if viewer.is_authenticated: + queryset = queryset.exclude( + blocks=viewer + ) + return queryset + def to_outbox(self, filter_type=None, **kwargs): ''' an ordered collection of statuses ''' if filter_type: @@ -131,7 +140,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): privacy__in=['public', 'unlisted'], ).select_subclasses().order_by('-published_date') return self.to_ordered_collection(queryset, \ - collection_only=True, remote_id=self.outbox, **kwargs) + collection_only=True, remote_id=self.outbox, **kwargs).serialize() def to_following_activity(self, **kwargs): ''' activitypub following list ''' @@ -172,15 +181,23 @@ class User(OrderedCollectionPageMixin, AbstractUser): def save(self, *args, **kwargs): ''' populate fields for new local users ''' - # this user already exists, no need to populate fields + created = not bool(self.id) if not self.local and not re.match(regex.full_username, self.username): # generate a username that uses the domain (webfinger format) actor_parts = urlparse(self.remote_id) self.username = '%s@%s' % (self.username, actor_parts.netloc) - return super().save(*args, **kwargs) + super().save(*args, **kwargs) - if self.id or not self.local: - return super().save(*args, **kwargs) + # this user already exists, no need to populate fields + if not created: + super().save(*args, **kwargs) + return + + # this is a new remote user, we need to set their remote server field + if not self.local: + super().save(*args, **kwargs) + set_remote_server.delay(self.id) + return # populate fields for local users self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname) @@ -188,7 +205,32 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.shared_inbox = 'https://%s/inbox' % DOMAIN self.outbox = '%s/outbox' % self.remote_id - return super().save(*args, **kwargs) + # an id needs to be set before we can proceed with related models + super().save(*args, **kwargs) + + # create keys and shelves for new local users + self.key_pair = KeyPair.objects.create( + remote_id='%s/#main-key' % self.remote_id) + self.save(broadcast=False) + + shelves = [{ + 'name': 'To Read', + 'identifier': 'to-read', + }, { + 'name': 'Currently Reading', + 'identifier': 'reading', + }, { + 'name': 'Read', + 'identifier': 'read', + }] + + for shelf in shelves: + Shelf( + name=shelf['name'], + identifier=shelf['identifier'], + user=self, + editable=False + ).save(broadcast=False) @property def local_path(self): @@ -280,42 +322,6 @@ class AnnualGoal(BookWyrmModel): finish_date__year__gte=self.year).count() - -@receiver(models.signals.post_save, sender=User) -#pylint: disable=unused-argument -def execute_after_save(sender, instance, created, *args, **kwargs): - ''' create shelves for new users ''' - if not created: - return - - if not instance.local: - set_remote_server.delay(instance.id) - return - - instance.key_pair = KeyPair.objects.create( - remote_id='%s/#main-key' % instance.remote_id) - instance.save(broadcast=False) - - shelves = [{ - 'name': 'To Read', - 'identifier': 'to-read', - }, { - 'name': 'Currently Reading', - 'identifier': 'reading', - }, { - 'name': 'Read', - 'identifier': 'read', - }] - - for shelf in shelves: - Shelf( - name=shelf['name'], - identifier=shelf['identifier'], - user=instance, - editable=False - ).save(broadcast=False) - - @app.task def set_remote_server(user_id): ''' figure out the user's remote server in the background ''' @@ -323,7 +329,7 @@ def set_remote_server(user_id): actor_parts = urlparse(user.remote_id) user.federated_server = \ get_or_create_remote_server(actor_parts.netloc) - user.save() + user.save(broadcast=False) if user.bookwyrm_user: get_remote_reviews.delay(user.outbox) @@ -337,19 +343,24 @@ def get_or_create_remote_server(domain): except FederatedServer.DoesNotExist: pass - data = get_data('https://%s/.well-known/nodeinfo' % domain) - try: - nodeinfo_url = data.get('links')[0].get('href') - except (TypeError, KeyError): - return None + data = get_data('https://%s/.well-known/nodeinfo' % domain) + try: + nodeinfo_url = data.get('links')[0].get('href') + except (TypeError, KeyError): + raise ConnectorException() + + data = get_data(nodeinfo_url) + application_type = data.get('software', {}).get('name') + application_version = data.get('software', {}).get('version') + except ConnectorException: + application_type = application_version = None - data = get_data(nodeinfo_url) server = FederatedServer.objects.create( server_name=domain, - application_type=data['software']['name'], - application_version=data['software']['version'], + application_type=application_type, + application_version=application_version, ) return server @@ -364,4 +375,4 @@ def get_remote_reviews(outbox): for activity in data['orderedItems']: if not activity['type'] == 'Review': continue - activitypub.Review(**activity).to_model(Review) + activitypub.Review(**activity).to_model() diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index 50ce101e..9d4b3105 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -100,9 +100,6 @@ .cover-container.is-medium { height: 100px; } - .cover-container.is-small { - height: 70px; - } } .cover-container.is-medium .no-cover div { diff --git a/bookwyrm/templates/about.html b/bookwyrm/templates/about.html deleted file mode 100644 index aa7426ca..00000000 --- a/bookwyrm/templates/about.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'layout.html' %} -{% block content %} - -
-
- {% include 'snippets/about.html' %} -
- -
-

Code of Conduct

-
- {{ site.code_of_conduct | safe }} -
-
-
-{% endblock %} diff --git a/bookwyrm/templates/author.html b/bookwyrm/templates/author.html index a875ad78..9f2054d0 100644 --- a/bookwyrm/templates/author.html +++ b/bookwyrm/templates/author.html @@ -2,7 +2,7 @@ {% load bookwyrm_tags %} {% block content %}
-
+

{{ author.name }}

diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index 0bef2856..dc7d4ce8 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -4,7 +4,7 @@ {% block content %}
-
+

{{ book.title }}{% if book.subtitle %}: @@ -42,26 +42,10 @@

Add cover

{% csrf_token %} -
-
-
- -
-
-
- -
-
+ +
{% endif %} @@ -242,7 +226,7 @@
{% for review in reviews %}
- {% 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 %}
{% endfor %} diff --git a/bookwyrm/templates/discover/about.html b/bookwyrm/templates/discover/about.html new file mode 100644 index 00000000..09115807 --- /dev/null +++ b/bookwyrm/templates/discover/about.html @@ -0,0 +1,22 @@ +{% extends 'layout.html' %} +{% block content %} + +
+

{{ site.name }}

+

{{ site.instance_tagline }}

+
+ +{% include 'discover/icons.html' %} + +
+ {% include 'snippets/about.html' %} +
+ +
+

Code of Conduct

+
+ {{ site.code_of_conduct | safe }} +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/discover.html b/bookwyrm/templates/discover/discover.html similarity index 62% rename from bookwyrm/templates/discover.html rename to bookwyrm/templates/discover/discover.html index 27e26e53..5a808bf7 100644 --- a/bookwyrm/templates/discover.html +++ b/bookwyrm/templates/discover/discover.html @@ -1,33 +1,14 @@ {% extends 'layout.html' %} {% block content %} -{% if not request.user.is_authenticated %}

{{ site.name }}

{{ site.instance_tagline }}

-
-
-
-

-

Decentralized

-
-
-
-
-

-

Friendly

-
-
-
-
-

-

Anti-Corporate

-
-
-
+{% include 'discover/icons.html' %} +{% if not request.user.is_authenticated %}
@@ -49,10 +30,6 @@
-{% else %} -
-

Discover

-
{% endif %}
@@ -63,18 +40,18 @@
- {% include 'snippets/discover/large-book.html' with book=books.0 %} + {% include 'discover/large-book.html' with book=books.0 %}
- {% include 'snippets/discover/small-book.html' with book=books.1 %} + {% include 'discover/small-book.html' with book=books.1 %}
- {% include 'snippets/discover/small-book.html' with book=books.2 %} + {% include 'discover/small-book.html' with book=books.2 %}
@@ -83,18 +60,18 @@
- {% include 'snippets/discover/small-book.html' with book=books.3 %} + {% include 'discover/small-book.html' with book=books.3 %}
- {% include 'snippets/discover/small-book.html' with book=books.4 %} + {% include 'discover/small-book.html' with book=books.4 %}
- {% include 'snippets/discover/large-book.html' with book=books.5 %} + {% include 'discover/large-book.html' with book=books.5 %}
diff --git a/bookwyrm/templates/discover/icons.html b/bookwyrm/templates/discover/icons.html new file mode 100644 index 00000000..a4086282 --- /dev/null +++ b/bookwyrm/templates/discover/icons.html @@ -0,0 +1,21 @@ +
+
+
+

+

Decentralized

+
+
+
+
+

+

Friendly

+
+
+
+
+

+

Anti-Corporate

+
+
+
+ diff --git a/bookwyrm/templates/snippets/discover/large-book.html b/bookwyrm/templates/discover/large-book.html similarity index 74% rename from bookwyrm/templates/snippets/discover/large-book.html rename to bookwyrm/templates/discover/large-book.html index 37b35947..7881a33a 100644 --- a/bookwyrm/templates/snippets/discover/large-book.html +++ b/bookwyrm/templates/discover/large-book.html @@ -2,8 +2,8 @@ {% if book %}
- {% include 'snippets/book_cover.html' with book=book size="large" %} - {% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} + {% include 'snippets/book_cover.html' with book=book size="large" %} + {% include 'snippets/stars.html' with rating=book|rating:request.user %}

{{ book.title }}

diff --git a/bookwyrm/templates/snippets/discover/small-book.html b/bookwyrm/templates/discover/small-book.html similarity index 60% rename from bookwyrm/templates/snippets/discover/small-book.html rename to bookwyrm/templates/discover/small-book.html index be399df6..72108c30 100644 --- a/bookwyrm/templates/snippets/discover/small-book.html +++ b/bookwyrm/templates/discover/small-book.html @@ -1,9 +1,7 @@ {% load bookwyrm_tags %} {% if book %} -{% include 'snippets/book_cover.html' with book=book %} -{% if ratings %} -{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} -{% endif %} +{% include 'snippets/book_cover.html' with book=book %} +{% include 'snippets/stars.html' with rating=book|rating:request.user %}

{{ book.title }}

{% if book.authors %} diff --git a/bookwyrm/templates/edit_book.html b/bookwyrm/templates/edit_book.html index 6e7e434e..fb0bb81c 100644 --- a/bookwyrm/templates/edit_book.html +++ b/bookwyrm/templates/edit_book.html @@ -62,7 +62,7 @@

Cover

-

{{ form.cover }}

+

{{ form.cover }}

{% for error in form.cover.errors %}

{{ error | escape }}

{% endfor %} diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html index 8c53cbeb..f3102ee7 100644 --- a/bookwyrm/templates/feed/direct_messages.html +++ b/bookwyrm/templates/feed/direct_messages.html @@ -16,7 +16,7 @@ {% endif %} {% for activity in activities %}
- {% include 'snippets/status.html' with status=activity %} + {% include 'snippets/status/status.html' with status=activity %}
{% endfor %} diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index 8d6152e2..7029fd69 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -32,7 +32,7 @@ {% endif %} {% for activity in activities %}
-{% include 'snippets/status.html' with status=activity %} +{% include 'snippets/status/status.html' with status=activity %}
{% endfor %} diff --git a/bookwyrm/templates/feed/thread.html b/bookwyrm/templates/feed/thread.html index aa67d5bb..18ab6ea3 100644 --- a/bookwyrm/templates/feed/thread.html +++ b/bookwyrm/templates/feed/thread.html @@ -8,7 +8,7 @@ {% endwith %} {% 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 %} {% for reply in status|replies %} diff --git a/bookwyrm/templates/goal.html b/bookwyrm/templates/goal.html index dfdc02aa..0dc0aef8 100644 --- a/bookwyrm/templates/goal.html +++ b/bookwyrm/templates/goal.html @@ -50,7 +50,7 @@ diff --git a/bookwyrm/templates/lists/list.html b/bookwyrm/templates/lists/list.html index 5f9baa03..c5aef606 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -19,7 +19,7 @@ {% for item in items %}
  • -
    +
    @@ -73,7 +73,7 @@ {% endif %} {% for book in suggested_books %} {% if book %} -
    +
    diff --git a/bookwyrm/templates/lists/list_layout.html b/bookwyrm/templates/lists/list_layout.html index d3ff2c48..f5855f27 100644 --- a/bookwyrm/templates/lists/list_layout.html +++ b/bookwyrm/templates/lists/list_layout.html @@ -2,7 +2,7 @@ {% load bookwyrm_tags %} {% block content %} -
    +

    {{ list.name }} {% include 'snippets/privacy-icons.html' with item=list %}

    Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}

    diff --git a/bookwyrm/templates/lists/lists.html b/bookwyrm/templates/lists/lists.html index 71d37250..259d0820 100644 --- a/bookwyrm/templates/lists/lists.html +++ b/bookwyrm/templates/lists/lists.html @@ -5,7 +5,7 @@

    Lists

    {% if request.user.is_authenticated and not lists.has_previous %} -
    +

    Your lists

    diff --git a/bookwyrm/templates/settings/manage_invites.html b/bookwyrm/templates/settings/manage_invites.html index 03b68b20..086615a9 100644 --- a/bookwyrm/templates/settings/manage_invites.html +++ b/bookwyrm/templates/settings/manage_invites.html @@ -2,28 +2,6 @@ {% block header %}Invites{% endblock %} {% load humanize %} {% block panel %} -
    - - - - - - - - {% if not invites %} - - {% endif %} - {% for invite in invites %} - - - - - - - {% endfor %} -
    LinkExpiresMax usesTimes used
    No active invites
    {{ invite.link }}{{ invite.expiry|naturaltime }}{{ invite.use_limit }}{{ invite.times_used }}
    -
    -

    Generate New Invite

    @@ -47,4 +25,27 @@
    + +
    + + + + + + + + {% if not invites %} + + {% endif %} + {% for invite in invites %} + + + + + + + {% endfor %} +
    LinkExpiresMax usesTimes used
    No active invites
    {{ invite.link }}{{ invite.expiry|naturaltime }}{{ invite.use_limit }}{{ invite.times_used }}
    + {% include 'snippets/pagination.html' with page=invites path=request.path %} +
    {% endblock %} diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html index 08daae64..bf914379 100644 --- a/bookwyrm/templates/snippets/boost_button.html +++ b/bookwyrm/templates/snippets/boost_button.html @@ -11,7 +11,7 @@
    {% csrf_token %} -
    +{% elif user in request.user.blocks.all %} +{% include 'snippets/block_button.html' %} {% else %} - - {% csrf_token %} - - {% if user.manually_approves_followers %} - - {% else %} - - {% endif %} - - +
    +
    + + +
    +
    + {% include 'snippets/user_options.html' with user=user class="is-small" %} +
    +
    {% endif %} diff --git a/bookwyrm/templates/snippets/rate_action.html b/bookwyrm/templates/snippets/rate_action.html index 833f2b88..cc94f675 100644 --- a/bookwyrm/templates/snippets/rate_action.html +++ b/bookwyrm/templates/snippets/rate_action.html @@ -9,7 +9,7 @@ -
    +
    {% for i in '12345'|make_list %} diff --git a/bookwyrm/templates/snippets/shelf.html b/bookwyrm/templates/snippets/shelf.html index 006bb4ea..0e4e9a08 100644 --- a/bookwyrm/templates/snippets/shelf.html +++ b/bookwyrm/templates/snippets/shelf.html @@ -1,6 +1,7 @@ {% load humanize %} {% load bookwyrm_tags %} {% if books|length > 0 %} +
    @@ -74,6 +75,7 @@ {% endfor %}
    +
    {% else %}

    This shelf is empty.

    {% if shelf.editable %} diff --git a/bookwyrm/templates/snippets/stars.html b/bookwyrm/templates/snippets/stars.html index 7d5b63d7..d1576807 100644 --- a/bookwyrm/templates/snippets/stars.html +++ b/bookwyrm/templates/snippets/stars.html @@ -1,7 +1,7 @@

    {% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %} {% for i in '12345'|make_list %} -

    diff --git a/bookwyrm/templates/snippets/book_preview.html b/bookwyrm/templates/snippets/status/book_preview.html similarity index 86% rename from bookwyrm/templates/snippets/book_preview.html rename to bookwyrm/templates/snippets/status/book_preview.html index 0c75f9b1..920b9f53 100644 --- a/bookwyrm/templates/snippets/book_preview.html +++ b/bookwyrm/templates/snippets/status/book_preview.html @@ -3,6 +3,7 @@
    {% include 'snippets/book_cover.html' with book=book %} + {% include 'snippets/stars.html' with rating=book|rating:request.user %} {% include 'snippets/shelve_button/shelve_button.html' with book=book %}
    diff --git a/bookwyrm/templates/snippets/status.html b/bookwyrm/templates/snippets/status/status.html similarity index 63% rename from bookwyrm/templates/snippets/status.html rename to bookwyrm/templates/snippets/status/status.html index 13d5ee7c..162fad97 100644 --- a/bookwyrm/templates/snippets/status.html +++ b/bookwyrm/templates/snippets/status/status.html @@ -4,8 +4,8 @@ {% include 'snippets/avatar.html' with user=status.user %} {% include 'snippets/username.html' with user=status.user %} boosted - {% include 'snippets/status_body.html' with status=status|boosted_status %} + {% include 'snippets/status/status_body.html' with status=status|boosted_status %} {% else %} - {% include 'snippets/status_body.html' with status=status %} + {% include 'snippets/status/status_body.html' with status=status %} {% endif %} {% endif %} diff --git a/bookwyrm/templates/snippets/status_body.html b/bookwyrm/templates/snippets/status/status_body.html similarity index 89% rename from bookwyrm/templates/snippets/status_body.html rename to bookwyrm/templates/snippets/status/status_body.html index 00ae5460..dfc0ac68 100644 --- a/bookwyrm/templates/snippets/status_body.html +++ b/bookwyrm/templates/snippets/status/status_body.html @@ -5,13 +5,13 @@ {% block card-header %}

    - {% include 'snippets/status_header.html' with status=status %} + {% include 'snippets/status/status_header.html' with status=status %}

    {% endblock %} {% block card-content %} - {% include 'snippets/status_content.html' with status=status %} + {% include 'snippets/status/status_content.html' with status=status %} {% endblock %} @@ -55,7 +55,7 @@ {{ status.published_date | post_date }}
    {% endblock %} diff --git a/bookwyrm/templates/snippets/status_content.html b/bookwyrm/templates/snippets/status/status_content.html similarity index 93% rename from bookwyrm/templates/snippets/status_content.html rename to bookwyrm/templates/snippets/status/status_content.html index d6dd5ef5..0f59f7fc 100644 --- a/bookwyrm/templates/snippets/status_content.html +++ b/bookwyrm/templates/snippets/status/status_content.html @@ -54,9 +54,9 @@ {% if status.book or status.mention_books.count %}
    {% 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 %} - {% 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 %} diff --git a/bookwyrm/templates/snippets/status_header.html b/bookwyrm/templates/snippets/status/status_header.html similarity index 100% rename from bookwyrm/templates/snippets/status_header.html rename to bookwyrm/templates/snippets/status/status_header.html diff --git a/bookwyrm/templates/snippets/status_options.html b/bookwyrm/templates/snippets/status/status_options.html similarity index 100% rename from bookwyrm/templates/snippets/status_options.html rename to bookwyrm/templates/snippets/status/status_options.html diff --git a/bookwyrm/templates/user/followers.html b/bookwyrm/templates/user/followers.html index 42b8cfb0..da6fc320 100644 --- a/bookwyrm/templates/user/followers.html +++ b/bookwyrm/templates/user/followers.html @@ -15,17 +15,13 @@

    Followers

    {% for followers in followers %} -
    -
    -
    - {% include 'snippets/avatar.html' with user=followers %} -
    -
    - {% include 'snippets/username.html' with user=followers show_full=True %} -
    -
    - {% include 'snippets/follow_button.html' with user=followers %} -
    +
    +
    + {% include 'snippets/avatar.html' with user=followers %} + {% include 'snippets/username.html' with user=followers show_full=True %} +
    +
    + {% include 'snippets/follow_button.html' with user=followers %}
    {% endfor %} diff --git a/bookwyrm/templates/user/following.html b/bookwyrm/templates/user/following.html index 9e42b783..d734b0dc 100644 --- a/bookwyrm/templates/user/following.html +++ b/bookwyrm/templates/user/following.html @@ -15,17 +15,13 @@

    Following

    {% for follower in user.following.all %} -
    -
    -
    - {% include 'snippets/avatar.html' with user=follower %} -
    -
    - {% include 'snippets/username.html' with user=follower show_full=True %} -
    -
    - {% include 'snippets/follow_button.html' with user=follower %} -
    +
    +
    + {% include 'snippets/avatar.html' with user=follower %} + {% include 'snippets/username.html' with user=follower show_full=True %} +
    +
    + {% include 'snippets/follow_button.html' with user=follower %}
    {% endfor %} diff --git a/bookwyrm/templates/user/lists.html b/bookwyrm/templates/user/lists.html index a006c92b..45e4806f 100644 --- a/bookwyrm/templates/user/lists.html +++ b/bookwyrm/templates/user/lists.html @@ -1,7 +1,7 @@ {% extends 'user/user_layout.html' %} {% block header %} -
    +

    {% if is_self %}Your diff --git a/bookwyrm/templates/user/shelf.html b/bookwyrm/templates/user/shelf.html index 5e2f532f..e62e2218 100644 --- a/bookwyrm/templates/user/shelf.html +++ b/bookwyrm/templates/user/shelf.html @@ -38,7 +38,7 @@ {% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %}

    -
    +

    {{ shelf.name }} diff --git a/bookwyrm/templates/user/user.html b/bookwyrm/templates/user/user.html index c7f6d3b5..7c8cb8aa 100644 --- a/bookwyrm/templates/user/user.html +++ b/bookwyrm/templates/user/user.html @@ -1,7 +1,7 @@ {% extends 'user/user_layout.html' %} {% block header %} -
    +

    User profile

    @@ -54,7 +54,7 @@ {% endif %}
    - {% if not is_self and request.user.is_authenticated %} -
    -
    - {% include 'snippets/follow_button.html' with user=user %} -
    -
    - {% include 'snippets/user_options.html' with user=user class="is-small" %} -
    -
    + {% include 'snippets/follow_button.html' with user=user %} {% endif %} {% if is_self and user.follower_requests.all %} diff --git a/bookwyrm/templatetags/bookwyrm_tags.py b/bookwyrm/templatetags/bookwyrm_tags.py index d251d8e4..67354ac6 100644 --- a/bookwyrm/templatetags/bookwyrm_tags.py +++ b/bookwyrm/templatetags/bookwyrm_tags.py @@ -22,11 +22,8 @@ def dict_key(d, k): @register.filter(name='rating') def get_rating(book, user): ''' get the overall rating of a book ''' - queryset = views.helpers.get_activity_feed( - user, - ['public', 'followers', 'unlisted', 'direct'], - queryset=models.Review.objects.filter(book=book), - ) + queryset = views.helpers.privacy_filter( + user, models.Review.objects.filter(book=book)) return queryset.aggregate(Avg('rating'))['rating__avg'] diff --git a/bookwyrm/tests/activitypub/test_base_activity.py b/bookwyrm/tests/activitypub/test_base_activity.py index e84d7674..d489fdaa 100644 --- a/bookwyrm/tests/activitypub/test_base_activity.py +++ b/bookwyrm/tests/activitypub/test_base_activity.py @@ -79,7 +79,7 @@ class BaseActivity(TestCase): def test_resolve_remote_id(self): ''' look up or load remote data ''' # existing item - result = resolve_remote_id(models.User, 'http://example.com/a/b') + result = resolve_remote_id('http://example.com/a/b', model=models.User) self.assertEqual(result, self.user) # remote item @@ -91,7 +91,7 @@ class BaseActivity(TestCase): with patch('bookwyrm.models.user.set_remote_server.delay'): result = resolve_remote_id( - models.User, 'https://example.com/user/mouse') + 'https://example.com/user/mouse', model=models.User) self.assertIsInstance(result, models.User) self.assertEqual(result.remote_id, 'https://example.com/user/mouse') self.assertEqual(result.name, 'MOUSE?? MOUSE!!') @@ -100,46 +100,8 @@ class BaseActivity(TestCase): ''' catch mismatch between activity type and model type ''' instance = ActivityObject(id='a', type='b') with self.assertRaises(ActivitySerializerError): - instance.to_model(models.User) + instance.to_model(model=models.User) - def test_to_model_simple_fields(self): - ''' test setting simple fields ''' - self.assertIsNone(self.user.name) - - activity = activitypub.Person( - id=self.user.remote_id, - name='New Name', - preferredUsername='mouse', - inbox='http://www.com/', - outbox='http://www.com/', - followers='', - summary='', - publicKey=None, - endpoints={}, - ) - - activity.to_model(models.User, self.user) - - self.assertEqual(self.user.name, 'New Name') - - def test_to_model_foreign_key(self): - ''' test setting one to one/foreign key ''' - activity = activitypub.Person( - id=self.user.remote_id, - name='New Name', - preferredUsername='mouse', - inbox='http://www.com/', - outbox='http://www.com/', - followers='', - summary='', - publicKey=self.user.key_pair.to_activity(), - endpoints={}, - ) - - activity.publicKey['publicKeyPem'] = 'hi im secure' - - activity.to_model(models.User, self.user) - self.assertEqual(self.user.key_pair.public_key, 'hi im secure') @responses.activate def test_to_model_image(self): @@ -152,9 +114,15 @@ class BaseActivity(TestCase): outbox='http://www.com/', followers='', summary='', - publicKey=None, + publicKey={ + 'id': 'hi', + 'owner': self.user.remote_id, + 'publicKeyPem': 'hi'}, endpoints={}, - icon={'url': 'http://www.example.com/image.jpg'} + icon={ + 'type': 'Image', + 'url': 'http://www.example.com/image.jpg' + } ) responses.add( @@ -169,9 +137,10 @@ class BaseActivity(TestCase): # this would trigger a broadcast because it's a local user with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - activity.to_model(models.User, self.user) - self.assertIsNotNone(self.user.avatar.name) + activity.to_model(model=models.User, instance=self.user) self.assertIsNotNone(self.user.avatar.file) + self.assertEqual(self.user.name, 'New Name') + self.assertEqual(self.user.key_pair.public_key, 'hi') def test_to_model_many_to_many(self): ''' annoying that these all need special handling ''' @@ -202,7 +171,7 @@ class BaseActivity(TestCase): }, ] ) - update_data.to_model(models.Status, instance=status) + update_data.to_model(model=models.Status, instance=status) self.assertEqual(status.mention_users.first(), self.user) self.assertEqual(status.mention_books.first(), book) @@ -239,7 +208,7 @@ class BaseActivity(TestCase): # sets the celery task call to the function call with patch( 'bookwyrm.activitypub.base_activity.set_related_field.delay'): - update_data.to_model(models.Status, instance=status) + update_data.to_model(model=models.Status, instance=status) self.assertIsNone(status.attachments.first()) diff --git a/bookwyrm/tests/activitypub/test_person.py b/bookwyrm/tests/activitypub/test_person.py index c7a8221c..06240281 100644 --- a/bookwyrm/tests/activitypub/test_person.py +++ b/bookwyrm/tests/activitypub/test_person.py @@ -25,7 +25,7 @@ class Person(TestCase): def test_user_to_model(self): activity = activitypub.Person(**self.user_data) with patch('bookwyrm.models.user.set_remote_server.delay'): - user = activity.to_model(models.User) + user = activity.to_model(model=models.User) self.assertEqual(user.username, 'mouse@example.com') self.assertEqual(user.remote_id, 'https://example.com/user/mouse') self.assertFalse(user.local) diff --git a/bookwyrm/tests/activitypub/test_quotation.py b/bookwyrm/tests/activitypub/test_quotation.py index 60920889..1cd1f05d 100644 --- a/bookwyrm/tests/activitypub/test_quotation.py +++ b/bookwyrm/tests/activitypub/test_quotation.py @@ -46,7 +46,7 @@ class Quotation(TestCase): def test_activity_to_model(self): ''' create a model instance from an activity object ''' activity = activitypub.Quotation(**self.status_data) - quotation = activity.to_model(models.Quotation) + quotation = activity.to_model(model=models.Quotation) self.assertEqual(quotation.book, self.book) self.assertEqual(quotation.user, self.user) diff --git a/bookwyrm/tests/connectors/test_openlibrary_connector.py b/bookwyrm/tests/connectors/test_openlibrary_connector.py index 8132a203..576e353b 100644 --- a/bookwyrm/tests/connectors/test_openlibrary_connector.py +++ b/bookwyrm/tests/connectors/test_openlibrary_connector.py @@ -92,11 +92,26 @@ class Openlibrary(TestCase): responses.add( responses.GET, 'https://openlibrary.org/authors/OL382982A', - json={'hi': 'there'}, + json={ + "name": "George Elliott", + "personal_name": "George Elliott", + "last_modified": { + "type": "/type/datetime", + "value": "2008-08-31 10:09:33.413686" + }, + "key": "/authors/OL453734A", + "type": { + "key": "/type/author" + }, + "id": 1259965, + "revision": 2 + }, status=200) results = self.connector.get_authors_from_data(self.work_data) - for result in results: - self.assertIsInstance(result, models.Author) + result = list(results)[0] + self.assertIsInstance(result, models.Author) + self.assertEqual(result.name, 'George Elliott') + self.assertEqual(result.openlibrary_key, 'OL453734A') def test_get_cover_url(self): @@ -201,8 +216,11 @@ class Openlibrary(TestCase): 'https://openlibrary.org/authors/OL382982A', json={'hi': 'there'}, status=200) - result = self.connector.create_edition_from_data( - work, self.edition_data) + with patch('bookwyrm.connectors.openlibrary.Connector.' \ + 'get_authors_from_data') as mock: + mock.return_value = [] + result = self.connector.create_edition_from_data( + work, self.edition_data) self.assertEqual(result.parent_work, work) self.assertEqual(result.title, 'Sabriel') self.assertEqual(result.isbn_10, '0060273224') diff --git a/bookwyrm/tests/models/test_activitypub_mixin.py b/bookwyrm/tests/models/test_activitypub_mixin.py index 1ea4ae6d..11b944d9 100644 --- a/bookwyrm/tests/models/test_activitypub_mixin.py +++ b/bookwyrm/tests/models/test_activitypub_mixin.py @@ -30,6 +30,12 @@ class ActivitypubMixins(TestCase): outbox='https://example.com/users/rat/outbox', ) + self.object_mock = { + 'to': 'to field', 'cc': 'cc field', + 'content': 'hi', 'id': 'bip', 'type': 'Test', + 'published': '2020-12-04T17:52:22.623807+00:00', + } + # ActivitypubMixin def test_to_activity(self): @@ -290,40 +296,12 @@ class ActivitypubMixins(TestCase): id=1, user=self.local_user, deleted=True).save() - def test_to_create_activity(self): - ''' wrapper for ActivityPub "create" action ''' - object_activity = { - 'to': 'to field', 'cc': 'cc field', - 'content': 'hi', - 'published': '2020-12-04T17:52:22.623807+00:00', - } - MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) - mock_self = MockSelf( - 'https://example.com/status/1', - lambda *args: object_activity - ) - activity = ObjectMixin.to_create_activity( - mock_self, self.local_user) - self.assertEqual( - activity['id'], - 'https://example.com/status/1/activity' - ) - self.assertEqual(activity['actor'], self.local_user.remote_id) - self.assertEqual(activity['type'], 'Create') - self.assertEqual(activity['to'], 'to field') - self.assertEqual(activity['cc'], 'cc field') - self.assertEqual(activity['object'], object_activity) - self.assertEqual( - activity['signature'].creator, - '%s#main-key' % self.local_user.remote_id - ) - def test_to_delete_activity(self): ''' wrapper for Delete activity ''' MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) mock_self = MockSelf( 'https://example.com/status/1', - lambda *args: {} + lambda *args: self.object_mock ) activity = ObjectMixin.to_delete_activity( mock_self, self.local_user) @@ -346,7 +324,7 @@ class ActivitypubMixins(TestCase): MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) mock_self = MockSelf( 'https://example.com/status/1', - lambda *args: {} + lambda *args: self.object_mock ) activity = ObjectMixin.to_update_activity( mock_self, self.local_user) @@ -361,7 +339,7 @@ class ActivitypubMixins(TestCase): self.assertEqual( activity['to'], ['https://www.w3.org/ns/activitystreams#Public']) - self.assertEqual(activity['object'], {}) + self.assertIsInstance(activity['object'], dict) # Activity mixin @@ -370,7 +348,7 @@ class ActivitypubMixins(TestCase): MockSelf = namedtuple('Self', ('remote_id', 'to_activity', 'user')) mock_self = MockSelf( 'https://example.com/status/1', - lambda *args: {}, + lambda *args: self.object_mock, self.local_user, ) activity = ActivityMixin.to_undo_activity(mock_self) @@ -380,4 +358,4 @@ class ActivitypubMixins(TestCase): ) self.assertEqual(activity['actor'], self.local_user.remote_id) self.assertEqual(activity['type'], 'Undo') - self.assertEqual(activity['object'], {}) + self.assertIsInstance(activity['object'], dict) diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index 38de9c98..24c0fb02 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -17,6 +17,7 @@ from django.db import models from django.test import TestCase from django.utils import timezone +from bookwyrm import activitypub from bookwyrm.activitypub.base_activity import ActivityObject from bookwyrm.models import fields, User, Status from bookwyrm.models.base_model import BookWyrmModel @@ -275,7 +276,7 @@ class ActivitypubFields(TestCase): 'rat', 'rat@rat.rat', 'ratword', local=True, localname='rat') with patch('bookwyrm.models.user.set_remote_server.delay'): - value = instance.field_from_activity(userdata) + value = instance.field_from_activity(activitypub.Person(**userdata)) self.assertIsInstance(value, User) self.assertNotEqual(value, unrelated_user) self.assertEqual(value.remote_id, 'https://example.com/user/mouse') @@ -300,7 +301,7 @@ class ActivitypubFields(TestCase): local=True, localname='rat') with patch('bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast'): - value = instance.field_from_activity(userdata) + value = instance.field_from_activity(activitypub.Person(**userdata)) self.assertEqual(value, user) diff --git a/bookwyrm/tests/models/test_import_model.py b/bookwyrm/tests/models/test_import_model.py index 146c60a7..7ec4e700 100644 --- a/bookwyrm/tests/models/test_import_model.py +++ b/bookwyrm/tests/models/test_import_model.py @@ -171,6 +171,8 @@ class ImportJob(TestCase): 'bookwyrm.connectors.connector_manager.first_search_result' ) as search: search.return_value = result - book = self.item_1.get_book_from_isbn() + with patch('bookwyrm.connectors.openlibrary.Connector.' \ + 'get_authors_from_data'): + book = self.item_1.get_book_from_isbn() self.assertEqual(book.title, 'Sabriel') diff --git a/bookwyrm/tests/models/test_relationship_models.py b/bookwyrm/tests/models/test_relationship_models.py index 97f267b6..0ef53450 100644 --- a/bookwyrm/tests/models/test_relationship_models.py +++ b/bookwyrm/tests/models/test_relationship_models.py @@ -23,19 +23,6 @@ class Relationship(TestCase): self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.save(broadcast=False) - def test_user_follows(self): - ''' create a follow relationship ''' - with patch('bookwyrm.models.activitypub_mixin.ActivityMixin.broadcast'): - rel = models.UserFollows.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) - - activity = rel.to_activity() - self.assertEqual(activity['id'], rel.remote_id) - self.assertEqual(activity['actor'], self.local_user.remote_id) - self.assertEqual(activity['object'], self.remote_user.remote_id) - def test_user_follows_from_request(self): ''' convert a follow request into a follow ''' @@ -116,13 +103,15 @@ class Relationship(TestCase): self.assertEqual(user.remote_id, self.local_user.remote_id) self.assertEqual(activity['type'], 'Accept') self.assertEqual(activity['actor'], self.local_user.remote_id) - self.assertEqual( - activity['object']['id'], request.remote_id) + self.assertEqual(activity['object']['id'], 'https://www.hi.com/') + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) models.UserFollowRequest.broadcast = mock_broadcast request = models.UserFollowRequest.objects.create( user_subject=self.remote_user, user_object=self.local_user, + remote_id='https://www.hi.com/' ) request.accept() @@ -145,6 +134,8 @@ class Relationship(TestCase): activity['object']['id'], request.remote_id) models.UserFollowRequest.broadcast = mock_reject + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) request = models.UserFollowRequest.objects.create( user_subject=self.remote_user, user_object=self.local_user, diff --git a/bookwyrm/tests/models/test_shelf_model.py b/bookwyrm/tests/models/test_shelf_model.py index 11b5ad5c..c997326e 100644 --- a/bookwyrm/tests/models/test_shelf_model.py +++ b/bookwyrm/tests/models/test_shelf_model.py @@ -4,6 +4,7 @@ from django.test import TestCase from bookwyrm import models, settings +#pylint: disable=unused-argument class Shelf(TestCase): ''' some activitypub oddness ahead ''' def setUp(self): diff --git a/bookwyrm/tests/models/test_status_model.py b/bookwyrm/tests/models/test_status_model.py index 6978d593..c6911b6d 100644 --- a/bookwyrm/tests/models/test_status_model.py +++ b/bookwyrm/tests/models/test_status_model.py @@ -63,7 +63,7 @@ class Status(TestCase): self.assertEqual(models.Review().status_type, 'Review') self.assertEqual(models.Quotation().status_type, 'Quotation') self.assertEqual(models.Comment().status_type, 'Comment') - self.assertEqual(models.Boost().status_type, 'Boost') + self.assertEqual(models.Boost().status_type, 'Announce') def test_boostable(self, _): ''' can a status be boosted, based on privacy ''' @@ -284,3 +284,24 @@ class Status(TestCase): with self.assertRaises(IntegrityError): models.Notification.objects.create( user=self.user, notification_type='GLORB') + + + def test_create_broadcast(self, broadcast_mock): + ''' should send out two verions of a status on create ''' + models.Comment.objects.create( + content='hi', user=self.user, book=self.book) + self.assertEqual(broadcast_mock.call_count, 2) + pure_call = broadcast_mock.call_args_list[0] + bw_call = broadcast_mock.call_args_list[1] + + self.assertEqual(pure_call[1]['software'], 'other') + args = pure_call[0][0] + self.assertEqual(args['type'], 'Create') + self.assertEqual(args['object']['type'], 'Note') + self.assertTrue('content' in args['object']) + + + self.assertEqual(bw_call[1]['software'], 'bookwyrm') + args = bw_call[0][0] + self.assertEqual(args['type'], 'Create') + self.assertEqual(args['object']['type'], 'Comment') diff --git a/bookwyrm/tests/models/test_user_model.py b/bookwyrm/tests/models/test_user_model.py index a10c89b8..ed3ad41a 100644 --- a/bookwyrm/tests/models/test_user_model.py +++ b/bookwyrm/tests/models/test_user_model.py @@ -1,16 +1,18 @@ ''' testing models ''' from unittest.mock import patch from django.test import TestCase +import responses from bookwyrm import models from bookwyrm.settings import DOMAIN - +# pylint: disable=missing-class-docstring +# pylint: disable=missing-function-docstring class User(TestCase): def setUp(self): self.user = models.User.objects.create_user( '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): ''' username instead of id here ''' @@ -28,7 +30,7 @@ class User(TestCase): with patch('bookwyrm.models.user.set_remote_server.delay'): user = models.User.objects.create_user( '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') @@ -62,7 +64,7 @@ class User(TestCase): self.assertEqual(activity['name'], self.user.name) self.assertEqual(activity['inbox'], self.user.inbox) 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['type'], 'Person') @@ -71,3 +73,83 @@ class User(TestCase): self.assertEqual(activity['type'], 'OrderedCollection') self.assertEqual(activity['id'], self.user.outbox) 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) diff --git a/bookwyrm/tests/test_signing.py b/bookwyrm/tests/test_signing.py index 0d55893d..f6de11e1 100644 --- a/bookwyrm/tests/test_signing.py +++ b/bookwyrm/tests/test_signing.py @@ -1,3 +1,4 @@ +''' getting and verifying signatures ''' import time from collections import namedtuple from urllib.parse import urlsplit @@ -12,31 +13,33 @@ import pytest from django.test import TestCase, Client from django.utils.http import http_date -from bookwyrm.models import User +from bookwyrm import models from bookwyrm.activitypub import Follow from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair, make_signature, make_digest -def get_follow_data(follower, followee): - follow_activity = Follow( +def get_follow_activity(follower, followee): + ''' generates a test activity ''' + return Follow( id='https://test.com/user/follow/id', actor=follower.remote_id, object=followee.remote_id, ).serialize() - return json.dumps(follow_activity) KeyPair = namedtuple('KeyPair', ('private_key', 'public_key')) Sender = namedtuple('Sender', ('remote_id', 'key_pair')) class Signature(TestCase): + ''' signature test ''' def setUp(self): - self.mouse = User.objects.create_user( + ''' create users and test data ''' + self.mouse = models.User.objects.create_user( 'mouse@%s' % DOMAIN, 'mouse@example.com', '', local=True, localname='mouse') - self.rat = User.objects.create_user( + self.rat = models.User.objects.create_user( 'rat@%s' % DOMAIN, 'rat@example.com', '', local=True, localname='rat') - self.cat = User.objects.create_user( + self.cat = models.User.objects.create_user( 'cat@%s' % DOMAIN, 'cat@example.com', '', local=True, localname='cat') @@ -47,6 +50,8 @@ class Signature(TestCase): KeyPair(private_key, public_key) ) + models.SiteSettings.objects.create() + def send(self, signature, now, data, digest): ''' test request ''' c = Client() @@ -63,7 +68,7 @@ class Signature(TestCase): } ) - def send_test_request( + def send_test_request(#pylint: disable=too-many-arguments self, sender, signer=None, @@ -72,15 +77,16 @@ class Signature(TestCase): date=None): ''' sends a follow request to the "rat" user ''' now = date or http_date() - data = json.dumps(get_follow_data(sender, self.rat)) + data = json.dumps(get_follow_activity(sender, self.rat)) digest = digest or make_digest(data) signature = make_signature( signer or sender, self.rat.inbox, now, digest) - with patch('bookwyrm.incoming.handle_follow.delay'): + with patch('bookwyrm.views.inbox.activity_task.delay'): with patch('bookwyrm.models.user.set_remote_server.delay'): return self.send(signature, now, send_data or data, digest) def test_correct_signature(self): + ''' this one should just work ''' response = self.send_test_request(sender=self.mouse) self.assertEqual(response.status_code, 200) @@ -120,6 +126,7 @@ class Signature(TestCase): @responses.activate def test_key_needs_refresh(self): + ''' an out of date key should be updated and the new key work ''' datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') data = json.loads(datafile.read_bytes()) data['id'] = self.fake_remote.remote_id @@ -165,6 +172,7 @@ class Signature(TestCase): @responses.activate def test_nonexistent_signer(self): + ''' fail when unable to look up signer ''' responses.add( responses.GET, self.fake_remote.remote_id, @@ -180,11 +188,12 @@ class Signature(TestCase): with patch('bookwyrm.activitypub.resolve_remote_id'): response = self.send_test_request( self.mouse, - send_data=get_follow_data(self.mouse, self.cat)) + send_data=get_follow_activity(self.mouse, self.cat)) self.assertEqual(response.status_code, 401) @pytest.mark.integration def test_invalid_digest(self): + ''' signature digest must be valid ''' with patch('bookwyrm.activitypub.resolve_remote_id'): response = self.send_test_request( self.mouse, diff --git a/bookwyrm/tests/views/test_follow.py b/bookwyrm/tests/views/test_follow.py index 943ffcf8..62543d2d 100644 --- a/bookwyrm/tests/views/test_follow.py +++ b/bookwyrm/tests/views/test_follow.py @@ -77,7 +77,6 @@ class BookViews(TestCase): self.assertEqual(rel.status, 'follow_request') - def test_handle_follow_local(self): ''' send a follow request ''' rat = models.User.objects.create_user( @@ -105,14 +104,18 @@ class BookViews(TestCase): request.user = self.local_user self.remote_user.followers.add(self.local_user) self.assertEqual(self.remote_user.followers.count(), 1) - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \ + as mock: views.unfollow(request) + self.assertEqual(mock.call_count, 1) self.assertEqual(self.remote_user.followers.count(), 0) def test_handle_accept(self): ''' accept a follow request ''' + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) request = self.factory.post('', {'user': self.remote_user.username}) request.user = self.local_user rel = models.UserFollowRequest.objects.create( @@ -132,6 +135,8 @@ class BookViews(TestCase): def test_handle_reject(self): ''' reject a follow request ''' + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) request = self.factory.post('', {'user': self.remote_user.username}) request.user = self.local_user rel = models.UserFollowRequest.objects.create( diff --git a/bookwyrm/tests/views/test_goal.py b/bookwyrm/tests/views/test_goal.py index a78216f2..0d534112 100644 --- a/bookwyrm/tests/views/test_goal.py +++ b/bookwyrm/tests/views/test_goal.py @@ -1,5 +1,6 @@ ''' test for app action functionality ''' from unittest.mock import patch +from django.utils import timezone from django.contrib.auth.models import AnonymousUser from django.template.response import TemplateResponse @@ -30,6 +31,7 @@ class GoalViews(TestCase): ) self.anonymous_user = AnonymousUser self.anonymous_user.is_authenticated = False + models.SiteSettings.objects.create() def test_goal_page_no_goal(self): @@ -48,6 +50,7 @@ class GoalViews(TestCase): request.user = self.local_user result = view(request, self.local_user.localname, 2020) + result.render() self.assertIsInstance(result, TemplateResponse) @@ -62,16 +65,23 @@ class GoalViews(TestCase): def test_goal_page_public(self): ''' 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( user=self.local_user, - year=2020, + year=timezone.now().year, goal=128937123, privacy='public') view = views.Goal.as_view() request = self.factory.get('') 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) def test_goal_page_private(self): diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index b75d61d5..eff08307 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -56,12 +56,14 @@ class ViewsHelpers(TestCase): def test_get_user_from_username(self): ''' works for either localname or username ''' 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( 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): - 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): @@ -104,7 +106,7 @@ class ViewsHelpers(TestCase): statuses = views.helpers.get_activity_feed( self.local_user, - ['public', 'unlisted', 'followers'], + privacy=['public', 'unlisted', 'followers'], following_only=True, queryset=models.Comment.objects ) @@ -113,20 +115,21 @@ class ViewsHelpers(TestCase): statuses = views.helpers.get_activity_feed( self.local_user, - ['public', 'followers'], + privacy=['public', 'followers'], local_only=True ) self.assertEqual(len(statuses), 2) self.assertEqual(statuses[1], public_status) 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(statuses[0], direct_status) statuses = views.helpers.get_activity_feed( self.local_user, - ['public', 'followers'], + privacy=['public', 'followers'], ) self.assertEqual(len(statuses), 3) self.assertEqual(statuses[2], public_status) @@ -135,7 +138,7 @@ class ViewsHelpers(TestCase): statuses = views.helpers.get_activity_feed( self.local_user, - ['public', 'unlisted', 'followers'], + privacy=['public', 'unlisted', 'followers'], following_only=True ) self.assertEqual(len(statuses), 2) @@ -145,7 +148,7 @@ class ViewsHelpers(TestCase): rat.followers.add(self.local_user) statuses = views.helpers.get_activity_feed( self.local_user, - ['public', 'unlisted', 'followers'], + privacy=['public', 'unlisted', 'followers'], following_only=True ) self.assertEqual(len(statuses), 5) @@ -168,18 +171,18 @@ class ViewsHelpers(TestCase): content='blah blah', user=rat) statuses = views.helpers.get_activity_feed( - self.local_user, ['public']) + self.local_user, privacy=['public']) self.assertEqual(len(statuses), 2) # block relationship rat.blocks.add(self.local_user) statuses = views.helpers.get_activity_feed( - self.local_user, ['public']) + self.local_user, privacy=['public']) self.assertEqual(len(statuses), 1) self.assertEqual(statuses[0], public_status) statuses = views.helpers.get_activity_feed( - rat, ['public']) + rat, privacy=['public']) self.assertEqual(len(statuses), 1) self.assertEqual(statuses[0], rat_public) @@ -188,18 +191,18 @@ class ViewsHelpers(TestCase): def test_is_bookwyrm_request(self): ''' checks if a request came from a bookwyrm instance ''' 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( '', {'q': 'Test Book'}, HTTP_USER_AGENT=\ "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( '', {'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): diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/views/test_inbox.py similarity index 63% rename from bookwyrm/tests/test_incoming.py rename to bookwyrm/tests/views/test_inbox.py index 01d0c9a3..ff55ad04 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/views/test_inbox.py @@ -1,23 +1,22 @@ -''' test incoming activities ''' +''' tests incoming activities''' from datetime import datetime import json import pathlib from unittest.mock import patch -from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \ - HttpResponseNotFound -from django.test import TestCase -from django.test.client import RequestFactory +from django.http import HttpResponseNotAllowed, HttpResponseNotFound +from django.test import TestCase, Client import responses -from bookwyrm import models, incoming +from bookwyrm import models, views #pylint: disable=too-many-public-methods -class Incoming(TestCase): - ''' a lot here: all handlers for receiving activitypub requests ''' +class Inbox(TestCase): + ''' readthrough tests ''' def setUp(self): - ''' we need basic things, like users ''' + ''' basic user and book data ''' + self.client = Client() self.local_user = models.User.objects.create_user( 'mouse@example.com', 'mouse@mouse.com', 'mouseword', local=True, localname='mouse') @@ -37,78 +36,207 @@ class Incoming(TestCase): content='Test status', remote_id='https://example.com/status/1', ) - self.factory = RequestFactory() + + self.create_json = { + 'id': 'hi', + 'type': 'Create', + 'actor': 'hi', + "to": [ + "https://www.w3.org/ns/activitystreams#public" + ], + "cc": [ + "https://example.com/user/mouse/followers" + ], + 'object': {} + } + models.SiteSettings.objects.create() def test_inbox_invalid_get(self): ''' shouldn't try to handle if the user is not found ''' - request = self.factory.get('https://www.example.com/') - self.assertIsInstance( - incoming.inbox(request, 'anything'), HttpResponseNotAllowed) - self.assertIsInstance( - incoming.shared_inbox(request), HttpResponseNotAllowed) + result = self.client.get( + '/inbox', content_type="application/json" + ) + self.assertIsInstance(result, HttpResponseNotAllowed) def test_inbox_invalid_user(self): ''' shouldn't try to handle if the user is not found ''' - request = self.factory.post('https://www.example.com/') - self.assertIsInstance( - incoming.inbox(request, 'fish@tomato.com'), HttpResponseNotFound) - - def test_inbox_invalid_no_object(self): - ''' json is missing "object" field ''' - request = self.factory.post( - self.local_user.shared_inbox, data={}) - self.assertIsInstance( - incoming.shared_inbox(request), HttpResponseBadRequest) + result = self.client.post( + '/user/bleh/inbox', + '{"type": "Test", "object": "exists"}', + content_type="application/json" + ) + self.assertIsInstance(result, HttpResponseNotFound) def test_inbox_invalid_bad_signature(self): ''' bad request for invalid signature ''' - request = self.factory.post( - self.local_user.shared_inbox, - '{"type": "Test", "object": "exists"}', - content_type='application/json') - with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: - mock_has_valid.return_value = False - self.assertEqual( - incoming.shared_inbox(request).status_code, 401) + with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid: + mock_valid.return_value = False + result = self.client.post( + '/user/mouse/inbox', + '{"type": "Test", "object": "exists"}', + content_type="application/json" + ) + self.assertEqual(result.status_code, 401) def test_inbox_invalid_bad_signature_delete(self): ''' invalid signature for Delete is okay though ''' - request = self.factory.post( - self.local_user.shared_inbox, - '{"type": "Delete", "object": "exists"}', - content_type='application/json') - with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: - mock_has_valid.return_value = False - self.assertEqual( - incoming.shared_inbox(request).status_code, 200) + with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid: + mock_valid.return_value = False + result = self.client.post( + '/user/mouse/inbox', + '{"type": "Delete", "object": "exists"}', + content_type="application/json" + ) + self.assertEqual(result.status_code, 200) def test_inbox_unknown_type(self): ''' never heard of that activity type, don't have a handler for it ''' - request = self.factory.post( - self.local_user.shared_inbox, - '{"type": "Fish", "object": "exists"}', - content_type='application/json') - with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: - mock_has_valid.return_value = True - self.assertIsInstance( - incoming.shared_inbox(request), HttpResponseNotFound) + with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid: + result = self.client.post( + '/inbox', + '{"type": "Fish", "object": "exists"}', + content_type="application/json" + ) + mock_valid.return_value = True + self.assertIsInstance(result, HttpResponseNotFound) + def test_inbox_success(self): ''' a known type, for which we start a task ''' - request = self.factory.post( - self.local_user.shared_inbox, - '{"type": "Accept", "object": "exists"}', - content_type='application/json') - with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: - mock_has_valid.return_value = True + activity = self.create_json + activity['object'] = { + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/user/mouse/followers" + ], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams" + } + with patch('bookwyrm.views.inbox.has_valid_signature') as mock_valid: + mock_valid.return_value = True - with patch('bookwyrm.incoming.handle_follow_accept.delay'): - self.assertEqual( - incoming.shared_inbox(request).status_code, 200) + with patch('bookwyrm.views.inbox.activity_task.delay'): + result = self.client.post( + '/inbox', + json.dumps(activity), + content_type="application/json" + ) + self.assertEqual(result.status_code, 200) - def test_handle_follow(self): + def test_handle_create_status(self): + ''' the "it justs works" mode ''' + self.assertEqual(models.Status.objects.count(), 1) + + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_quotation.json') + status_data = json.loads(datafile.read_bytes()) + models.Edition.objects.create( + title='Test Book', remote_id='https://example.com/book/1') + activity = self.create_json + activity['object'] = status_data + + views.inbox.activity_task(activity) + + status = models.Quotation.objects.get() + self.assertEqual( + status.remote_id, 'https://example.com/user/mouse/quotation/13') + self.assertEqual(status.quote, 'quote body') + self.assertEqual(status.content, 'commentary') + self.assertEqual(status.user, self.local_user) + self.assertEqual(models.Status.objects.count(), 2) + + # while we're here, lets ensure we avoid dupes + views.inbox.activity_task(activity) + self.assertEqual(models.Status.objects.count(), 2) + + + def test_handle_create_status_remote_note_with_mention(self): + ''' should only create it under the right circumstances ''' + self.assertEqual(models.Status.objects.count(), 1) + self.assertFalse( + models.Notification.objects.filter(user=self.local_user).exists()) + + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_note.json') + status_data = json.loads(datafile.read_bytes()) + activity = self.create_json + activity['object'] = status_data + + views.inbox.activity_task(activity) + status = models.Status.objects.last() + self.assertEqual(status.content, 'test content in note') + self.assertEqual(status.mention_users.first(), self.local_user) + self.assertTrue( + models.Notification.objects.filter(user=self.local_user).exists()) + self.assertEqual( + models.Notification.objects.get().notification_type, 'MENTION') + + def test_handle_create_status_remote_note_with_reply(self): + ''' should only create it under the right circumstances ''' + self.assertEqual(models.Status.objects.count(), 1) + self.assertFalse( + models.Notification.objects.filter(user=self.local_user)) + + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_note.json') + status_data = json.loads(datafile.read_bytes()) + del status_data['tag'] + status_data['inReplyTo'] = self.status.remote_id + activity = self.create_json + activity['object'] = status_data + + views.inbox.activity_task(activity) + status = models.Status.objects.last() + self.assertEqual(status.content, 'test content in note') + self.assertEqual(status.reply_parent, self.status) + self.assertTrue( + models.Notification.objects.filter(user=self.local_user)) + self.assertEqual( + models.Notification.objects.get().notification_type, 'REPLY') + + + def test_handle_create_list(self): + ''' a new list ''' + activity = self.create_json + activity['object'] = { + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/user/mouse/followers" + ], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams" + } + views.inbox.activity_task(activity) + book_list = models.List.objects.get() + self.assertEqual(book_list.name, 'Test List') + self.assertEqual(book_list.curation, 'curated') + self.assertEqual(book_list.description, 'summary text') + self.assertEqual(book_list.remote_id, 'https://example.com/list/22') + + + def test_handle_follow_x(self): ''' remote user wants to follow local user ''' activity = { "@context": "https://www.w3.org/ns/activitystreams", @@ -118,8 +246,11 @@ class Incoming(TestCase): "object": "https://example.com/user/mouse" } - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - incoming.handle_follow(activity) + self.assertFalse(models.UserFollowRequest.objects.exists()) + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \ + as mock: + views.inbox.activity_task(activity) + self.assertEqual(mock.call_count, 1) # notification created notification = models.Notification.objects.get() @@ -127,8 +258,7 @@ class Incoming(TestCase): self.assertEqual(notification.notification_type, 'FOLLOW') # the request should have been deleted - requests = models.UserFollowRequest.objects.all() - self.assertEqual(list(requests), []) + self.assertFalse(models.UserFollowRequest.objects.exists()) # the follow relationship should exist follow = models.UserFollows.objects.get(user_object=self.local_user) @@ -149,7 +279,7 @@ class Incoming(TestCase): self.local_user.save(broadcast=False) with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - incoming.handle_follow(activity) + views.inbox.activity_task(activity) # notification created notification = models.Notification.objects.get() @@ -168,48 +298,52 @@ class Incoming(TestCase): def test_handle_unfollow(self): ''' remove a relationship ''' + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + rel = models.UserFollows.objects.create( + user_subject=self.remote_user, user_object=self.local_user) activity = { "type": "Undo", + "id": "bleh", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + 'actor': self.remote_user.remote_id, "@context": "https://www.w3.org/ns/activitystreams", "object": { - "id": "https://example.com/users/rat/follows/123", + "id": rel.remote_id, "type": "Follow", "actor": "https://example.com/users/rat", "object": "https://example.com/user/mouse" } } - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - models.UserFollows.objects.create( - user_subject=self.remote_user, user_object=self.local_user) self.assertEqual(self.remote_user, self.local_user.followers.first()) - incoming.handle_unfollow(activity) + views.inbox.activity_task(activity) self.assertIsNone(self.local_user.followers.first()) def test_handle_follow_accept(self): ''' a remote user approved a follow request from local ''' + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) activity = { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://example.com/users/rat/follows/123#accepts", "type": "Accept", "actor": "https://example.com/users/rat", "object": { - "id": "https://example.com/users/rat/follows/123", + "id": rel.remote_id, "type": "Follow", "actor": "https://example.com/user/mouse", "object": "https://example.com/users/rat" } } - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - models.UserFollowRequest.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) self.assertEqual(models.UserFollowRequest.objects.count(), 1) - incoming.handle_follow_accept(activity) + views.inbox.activity_task(activity) # request should be deleted self.assertEqual(models.UserFollowRequest.objects.count(), 0) @@ -222,64 +356,31 @@ class Incoming(TestCase): def test_handle_follow_reject(self): ''' turn down a follow request ''' + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) activity = { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://example.com/users/rat/follows/123#accepts", "type": "Reject", "actor": "https://example.com/users/rat", "object": { - "id": "https://example.com/users/rat/follows/123", + "id": rel.remote_id, "type": "Follow", "actor": "https://example.com/user/mouse", "object": "https://example.com/users/rat" } } - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): - models.UserFollowRequest.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) self.assertEqual(models.UserFollowRequest.objects.count(), 1) - incoming.handle_follow_reject(activity) + views.inbox.activity_task(activity) # request should be deleted - self.assertEqual(models.UserFollowRequest.objects.count(), 0) - - # relationship should be created - follows = self.remote_user.followers - self.assertEqual(follows.count(), 0) - - - def test_handle_create_list(self): - ''' a new list ''' - activity = { - 'object': { - "id": "https://example.com/list/22", - "type": "BookList", - "totalItems": 1, - "first": "https://example.com/list/22?page=1", - "last": "https://example.com/list/22?page=1", - "name": "Test List", - "owner": "https://example.com/user/mouse", - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - "https://example.com/user/mouse/followers" - ], - "summary": "summary text", - "curation": "curated", - "@context": "https://www.w3.org/ns/activitystreams" - } - } - incoming.handle_create_list(activity) - book_list = models.List.objects.get() - self.assertEqual(book_list.name, 'Test List') - self.assertEqual(book_list.curation, 'curated') - self.assertEqual(book_list.description, 'summary text') - self.assertEqual(book_list.remote_id, 'https://example.com/list/22') + self.assertFalse(models.UserFollowRequest.objects.exists()) + self.assertFalse(self.remote_user.followers.exists()) def test_handle_update_list(self): @@ -289,6 +390,9 @@ class Incoming(TestCase): name='hi', remote_id='https://example.com/list/22', user=self.local_user) activity = { + 'type': 'Update', + 'to': [], 'cc': [], 'actor': 'hi', + 'id': 'sdkjf', 'object': { "id": "https://example.com/list/22", "type": "BookList", @@ -308,7 +412,7 @@ class Incoming(TestCase): "@context": "https://www.w3.org/ns/activitystreams" } } - incoming.handle_update_list(activity) + views.inbox.activity_task(activity) book_list.refresh_from_db() self.assertEqual(book_list.name, 'Test List') self.assertEqual(book_list.curation, 'curated') @@ -316,80 +420,6 @@ class Incoming(TestCase): self.assertEqual(book_list.remote_id, 'https://example.com/list/22') - def test_handle_create_status(self): - ''' the "it justs works" mode ''' - self.assertEqual(models.Status.objects.count(), 1) - - datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_quotation.json') - status_data = json.loads(datafile.read_bytes()) - models.Edition.objects.create( - title='Test Book', remote_id='https://example.com/book/1') - activity = {'object': status_data, 'type': 'Create'} - - incoming.handle_create_status(activity) - - status = models.Quotation.objects.get() - self.assertEqual( - status.remote_id, 'https://example.com/user/mouse/quotation/13') - self.assertEqual(status.quote, 'quote body') - self.assertEqual(status.content, 'commentary') - self.assertEqual(status.user, self.local_user) - self.assertEqual(models.Status.objects.count(), 2) - - # while we're here, lets ensure we avoid dupes - incoming.handle_create_status(activity) - self.assertEqual(models.Status.objects.count(), 2) - - def test_handle_create_status_unknown_type(self): - ''' folks send you all kinds of things ''' - activity = {'object': {'id': 'hi'}, 'type': 'Fish'} - result = incoming.handle_create_status(activity) - self.assertIsNone(result) - - def test_handle_create_status_remote_note_with_mention(self): - ''' should only create it under the right circumstances ''' - self.assertEqual(models.Status.objects.count(), 1) - self.assertFalse( - models.Notification.objects.filter(user=self.local_user).exists()) - - datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_note.json') - status_data = json.loads(datafile.read_bytes()) - activity = {'object': status_data, 'type': 'Create'} - - incoming.handle_create_status(activity) - status = models.Status.objects.last() - self.assertEqual(status.content, 'test content in note') - self.assertEqual(status.mention_users.first(), self.local_user) - self.assertTrue( - models.Notification.objects.filter(user=self.local_user).exists()) - self.assertEqual( - models.Notification.objects.get().notification_type, 'MENTION') - - def test_handle_create_status_remote_note_with_reply(self): - ''' should only create it under the right circumstances ''' - self.assertEqual(models.Status.objects.count(), 1) - self.assertFalse( - models.Notification.objects.filter(user=self.local_user)) - - datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_note.json') - status_data = json.loads(datafile.read_bytes()) - del status_data['tag'] - status_data['inReplyTo'] = self.status.remote_id - activity = {'object': status_data, 'type': 'Create'} - - incoming.handle_create_status(activity) - status = models.Status.objects.last() - self.assertEqual(status.content, 'test content in note') - self.assertEqual(status.reply_parent, self.status) - self.assertTrue( - models.Notification.objects.filter(user=self.local_user)) - self.assertEqual( - models.Notification.objects.get().notification_type, 'REPLY') - - def test_handle_delete_status(self): ''' remove a status ''' self.status.user = self.remote_user @@ -398,11 +428,13 @@ class Incoming(TestCase): self.assertFalse(self.status.deleted) activity = { 'type': 'Delete', + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], 'id': '%s/activity' % self.status.remote_id, 'actor': self.remote_user.remote_id, - 'object': {'id': self.status.remote_id}, + 'object': {'id': self.status.remote_id, 'type': 'Tombstone'}, } - incoming.handle_delete_status(activity) + views.inbox.activity_task(activity) # deletion doens't remove the status, it turns it into a tombstone status = models.Status.objects.get() self.assertTrue(status.deleted) @@ -427,11 +459,13 @@ class Incoming(TestCase): self.assertEqual(models.Notification.objects.count(), 2) activity = { 'type': 'Delete', + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], 'id': '%s/activity' % self.status.remote_id, 'actor': self.remote_user.remote_id, - 'object': {'id': self.status.remote_id}, + 'object': {'id': self.status.remote_id, 'type': 'Tombstone'}, } - incoming.handle_delete_status(activity) + views.inbox.activity_task(activity) # deletion doens't remove the status, it turns it into a tombstone status = models.Status.objects.get() self.assertTrue(status.deleted) @@ -448,11 +482,12 @@ class Incoming(TestCase): '@context': 'https://www.w3.org/ns/activitystreams', 'id': 'https://example.com/fav/1', 'actor': 'https://example.com/users/rat', + 'type': 'Like', 'published': 'Mon, 25 May 2020 19:31:20 GMT', 'object': 'https://example.com/status/1', } - incoming.handle_favorite(activity) + views.inbox.activity_task(activity) fav = models.Favorite.objects.get(remote_id='https://example.com/fav/1') self.assertEqual(fav.status, self.status) @@ -464,12 +499,16 @@ class Incoming(TestCase): activity = { 'id': 'https://example.com/fav/1#undo', 'type': 'Undo', + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + 'actor': self.remote_user.remote_id, 'object': { '@context': 'https://www.w3.org/ns/activitystreams', 'id': 'https://example.com/fav/1', 'actor': 'https://example.com/users/rat', + 'type': 'Like', 'published': 'Mon, 25 May 2020 19:31:20 GMT', - 'object': 'https://example.com/fav/1', + 'object': self.status.remote_id, } } models.Favorite.objects.create( @@ -478,7 +517,7 @@ class Incoming(TestCase): remote_id='https://example.com/fav/1') self.assertEqual(models.Favorite.objects.count(), 1) - incoming.handle_unfavorite(activity) + views.inbox.activity_task(activity) self.assertEqual(models.Favorite.objects.count(), 0) @@ -489,12 +528,12 @@ class Incoming(TestCase): 'type': 'Announce', 'id': '%s/boost' % self.status.remote_id, 'actor': self.remote_user.remote_id, - 'object': self.status.to_activity(), + 'object': self.status.remote_id, } with patch('bookwyrm.models.status.Status.ignore_activity') \ as discarder: discarder.return_value = False - incoming.handle_boost(activity) + views.inbox.activity_task(activity) boost = models.Boost.objects.get() self.assertEqual(boost.boosted_status, self.status) notification = models.Notification.objects.get() @@ -505,41 +544,52 @@ class Incoming(TestCase): @responses.activate def test_handle_discarded_boost(self): ''' test a boost of a mastodon status that will be discarded ''' + status = models.Status( + content='hi', + user=self.remote_user, + ) + status.save(broadcast=False) activity = { 'type': 'Announce', 'id': 'http://www.faraway.com/boost/12', 'actor': self.remote_user.remote_id, - 'object': self.status.to_activity(), + 'object': status.remote_id, } responses.add( responses.GET, - 'http://www.faraway.com/boost/12', - json={'id': 'http://www.faraway.com/boost/12'}, + status.remote_id, + json=status.to_activity(), status=200) - incoming.handle_boost(activity) + views.inbox.activity_task(activity) self.assertEqual(models.Boost.objects.count(), 0) def test_handle_unboost(self): ''' undo a boost ''' + boost = models.Boost.objects.create( + boosted_status=self.status, user=self.remote_user) activity = { 'type': 'Undo', + 'actor': 'hi', + 'id': 'bleh', + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], 'object': { 'type': 'Announce', - 'id': '%s/boost' % self.status.remote_id, - 'actor': self.local_user.remote_id, - 'object': self.status.to_activity(), + 'id': boost.remote_id, + 'actor': self.remote_user.remote_id, + 'object': self.status.remote_id, } } - models.Boost.objects.create( - boosted_status=self.status, user=self.remote_user) - incoming.handle_unboost(activity) + views.inbox.activity_task(activity) - def test_handle_add_book(self): + def test_handle_add_book_to_shelf(self): ''' shelving a book ''' + work = models.Work.objects.create(title='work title') book = models.Edition.objects.create( - title='Test', remote_id='https://bookwyrm.social/book/37292') + title='Test', remote_id='https://bookwyrm.social/book/37292', + parent_work=work) shelf = models.Shelf.objects.create( user=self.remote_user, name='Test Shelf') shelf.remote_id = 'https://bookwyrm.social/user/mouse/shelf/to-read' @@ -549,14 +599,113 @@ class Incoming(TestCase): "id": "https://bookwyrm.social/shelfbook/6189#add", "type": "Add", "actor": "https://example.com/users/rat", - "object": "https://bookwyrm.social/book/37292", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, "target": "https://bookwyrm.social/user/mouse/shelf/to-read", "@context": "https://www.w3.org/ns/activitystreams" } - incoming.handle_add(activity) + views.inbox.activity_task(activity) self.assertEqual(shelf.books.first(), book) + @responses.activate + def test_handle_add_book_to_list(self): + ''' listing a book ''' + work = models.Work.objects.create(title='work title') + book = models.Edition.objects.create( + title='Test', remote_id='https://bookwyrm.social/book/37292', + parent_work=work) + + responses.add( + responses.GET, + 'https://bookwyrm.social/user/mouse/list/to-read', + json={ + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/user/mouse/followers" + ], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams" + } + ) + + activity = { + "id": "https://bookwyrm.social/listbook/6189#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://bookwyrm.social/user/mouse/list/to-read", + "@context": "https://www.w3.org/ns/activitystreams" + } + views.inbox.activity_task(activity) + + booklist = models.List.objects.get() + self.assertEqual(booklist.name, 'Test List') + self.assertEqual(booklist.books.first(), book) + + + @responses.activate + def test_handle_tag_book(self): + ''' listing a book ''' + work = models.Work.objects.create(title='work title') + book = models.Edition.objects.create( + title='Test', remote_id='https://bookwyrm.social/book/37292', + parent_work=work) + + responses.add( + responses.GET, + 'https://www.example.com/tag/cool-tag', + json={ + "id": "https://1b1a78582461.ngrok.io/tag/tag", + "type": "OrderedCollection", + "totalItems": 0, + "first": "https://1b1a78582461.ngrok.io/tag/tag?page=1", + "last": "https://1b1a78582461.ngrok.io/tag/tag?page=1", + "name": "cool tag", + "@context": "https://www.w3.org/ns/activitystreams" + } + ) + + activity = { + "id": "https://bookwyrm.social/listbook/6189#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://www.example.com/tag/cool-tag", + "@context": "https://www.w3.org/ns/activitystreams" + } + views.inbox.activity_task(activity) + + tag = models.Tag.objects.get() + self.assertFalse(models.List.objects.exists()) + self.assertEqual(tag.name, 'cool tag') + self.assertEqual(tag.books.first(), book) + + def test_handle_update_user(self): ''' update an existing user ''' # we only do this with remote users @@ -564,11 +713,16 @@ class Incoming(TestCase): self.local_user.save() datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_user.json') + '../data/ap_user.json') userdata = json.loads(datafile.read_bytes()) del userdata['icon'] self.assertIsNone(self.local_user.name) - incoming.handle_update_user({'object': userdata}) + views.inbox.activity_task({ + 'type': 'Update', + 'to': [], 'cc': [], 'actor': 'hi', + 'id': 'sdkjf', + 'object': userdata + }) user = models.User.objects.get(id=self.local_user.id) self.assertEqual(user.name, 'MOUSE?? MOUSE!!') self.assertEqual(user.username, 'mouse@example.com') @@ -578,7 +732,7 @@ class Incoming(TestCase): def test_handle_update_edition(self): ''' update an existing edition ''' datafile = pathlib.Path(__file__).parent.joinpath( - 'data/bw_edition.json') + '../data/bw_edition.json') bookdata = json.loads(datafile.read_bytes()) models.Work.objects.create( @@ -591,7 +745,12 @@ class Incoming(TestCase): with patch( 'bookwyrm.activitypub.base_activity.set_related_field.delay'): - incoming.handle_update_edition({'object': bookdata}) + views.inbox.activity_task({ + 'type': 'Update', + 'to': [], 'cc': [], 'actor': 'hi', + 'id': 'sdkjf', + 'object': bookdata + }) book = models.Edition.objects.get(id=book.id) self.assertEqual(book.title, 'Piranesi') @@ -599,7 +758,7 @@ class Incoming(TestCase): def test_handle_update_work(self): ''' update an existing edition ''' datafile = pathlib.Path(__file__).parent.joinpath( - 'data/bw_work.json') + '../data/bw_work.json') bookdata = json.loads(datafile.read_bytes()) book = models.Work.objects.create( @@ -609,7 +768,12 @@ class Incoming(TestCase): self.assertEqual(book.title, 'Test Book') with patch( 'bookwyrm.activitypub.base_activity.set_related_field.delay'): - incoming.handle_update_work({'object': bookdata}) + views.inbox.activity_task({ + 'type': 'Update', + 'to': [], 'cc': [], 'actor': 'hi', + 'id': 'sdkjf', + 'object': bookdata + }) book = models.Work.objects.get(id=book.id) self.assertEqual(book.title, 'Piranesi') @@ -632,7 +796,7 @@ class Incoming(TestCase): "object": "https://example.com/user/mouse" } - incoming.handle_block(activity) + views.inbox.activity_task(activity) block = models.UserBlocks.objects.get() self.assertEqual(block.user_subject, self.remote_user) self.assertEqual(block.user_object, self.local_user) @@ -653,12 +817,18 @@ class Incoming(TestCase): self.assertEqual(block.user_subject, self.remote_user) self.assertEqual(block.user_object, self.local_user) - activity = {'type': 'Undo', 'object': { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/9e1f41ac-9ddd-4159", - "type": "Block", - "actor": "https://example.com/users/rat", - "object": "https://example.com/user/mouse" - }} - incoming.handle_unblock(activity) + activity = { + 'type': 'Undo', + 'actor': 'hi', + 'id': 'bleh', + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + 'object': { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/9e1f41ac-9ddd-4159", + "type": "Block", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse" + }} + views.inbox.activity_task(activity) self.assertFalse(models.UserBlocks.objects.exists()) diff --git a/bookwyrm/tests/views/test_interaction.py b/bookwyrm/tests/views/test_interaction.py index da6d5f9c..c6d39f29 100644 --- a/bookwyrm/tests/views/test_interaction.py +++ b/bookwyrm/tests/views/test_interaction.py @@ -138,7 +138,7 @@ class InteractionViews(TestCase): ''' undo a boost ''' view = views.Unboost.as_view() request = self.factory.post('') - request.user = self.remote_user + request.user = self.local_user with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): status = models.Status.objects.create( user=self.local_user, content='hi') @@ -146,7 +146,9 @@ class InteractionViews(TestCase): self.assertEqual(models.Boost.objects.count(), 1) self.assertEqual(models.Notification.objects.count(), 1) - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \ + as mock: view(request, status.id) + self.assertEqual(mock.call_count, 1) self.assertEqual(models.Boost.objects.count(), 0) self.assertEqual(models.Notification.objects.count(), 0) diff --git a/bookwyrm/tests/views/test_list.py b/bookwyrm/tests/views/test_list.py index 8737e347..e41d9806 100644 --- a/bookwyrm/tests/views/test_list.py +++ b/bookwyrm/tests/views/test_list.py @@ -9,7 +9,7 @@ from django.test.client import RequestFactory from bookwyrm import models, views from bookwyrm.activitypub import ActivitypubResponse - +#pylint: disable=unused-argument class ListViews(TestCase): ''' tag views''' def setUp(self): @@ -45,7 +45,7 @@ class ListViews(TestCase): with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): models.List.objects.create(name='Public list', user=self.local_user) models.List.objects.create( - name='Private list', privacy='private', user=self.local_user) + name='Private list', privacy='direct', user=self.local_user) request = self.factory.get('') request.user = self.local_user @@ -164,7 +164,7 @@ class ListViews(TestCase): with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): models.List.objects.create(name='Public list', user=self.local_user) models.List.objects.create( - name='Private list', privacy='private', user=self.local_user) + name='Private list', privacy='direct', user=self.local_user) request = self.factory.get('') request.user = self.local_user diff --git a/bookwyrm/tests/views/test_outbox.py b/bookwyrm/tests/views/test_outbox.py index d59f028c..7986dea6 100644 --- a/bookwyrm/tests/views/test_outbox.py +++ b/bookwyrm/tests/views/test_outbox.py @@ -7,6 +7,7 @@ from django.test import TestCase from django.test.client import RequestFactory from bookwyrm import models, views +from bookwyrm.settings import USER_AGENT # pylint: disable=too-many-public-methods @@ -90,3 +91,39 @@ class OutboxView(TestCase): data = json.loads(result.content) self.assertEqual(data['type'], 'OrderedCollection') 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') diff --git a/bookwyrm/tests/views/test_rss_feed.py b/bookwyrm/tests/views/test_rss_feed.py index 2b5e415e..3d5bec49 100644 --- a/bookwyrm/tests/views/test_rss_feed.py +++ b/bookwyrm/tests/views/test_rss_feed.py @@ -41,6 +41,7 @@ class RssFeedView(TestCase): ''' load an rss feed ''' view = rss_feed.RssFeed() request = self.factory.get('/user/rss_user/rss') + request.user = self.user with patch("bookwyrm.models.SiteSettings.objects.get") as site: site.return_value = self.site result = view(request, username=self.user.username) diff --git a/bookwyrm/tests/views/test_status.py b/bookwyrm/tests/views/test_status.py index d490f484..4594a203 100644 --- a/bookwyrm/tests/views/test_status.py +++ b/bookwyrm/tests/views/test_status.py @@ -1,4 +1,5 @@ ''' test for app action functionality ''' +import json from unittest.mock import patch from django.test import TestCase from django.test.client import RequestFactory @@ -236,7 +237,11 @@ class StatusViews(TestCase): self.assertFalse(status.deleted) request = self.factory.post('') request.user = self.local_user - with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \ + as mock: view(request, status.id) + activity = json.loads(mock.call_args_list[0][0][1]) + self.assertEqual(activity['type'], 'Delete') + self.assertEqual(activity['object']['type'], 'Tombstone') status.refresh_from_db() self.assertTrue(status.deleted) diff --git a/bookwyrm/tests/views/test_tag.py b/bookwyrm/tests/views/test_tag.py index 21a7e22e..ef809b46 100644 --- a/bookwyrm/tests/views/test_tag.py +++ b/bookwyrm/tests/views/test_tag.py @@ -59,6 +59,21 @@ class TagViews(TestCase): self.assertEqual(result.status_code, 200) + def test_tag_page_activitypub_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Tag.as_view() + with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'): + tag = models.Tag.objects.create(name='hi there') + models.UserTag.objects.create( + tag=tag, user=self.local_user, book=self.book) + request = self.factory.get('', {'page': 1}) + with patch('bookwyrm.views.tag.is_api_request') as is_api: + is_api.return_value = True + result = view(request, tag.identifier) + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + def test_tag(self): ''' add a tag to a book ''' view = views.AddTag.as_view() diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 3830adb9..a741088a 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -4,7 +4,7 @@ from django.contrib import admin from django.urls import path, re_path -from bookwyrm import incoming, settings, views, wellknown +from bookwyrm import settings, views, wellknown from bookwyrm.utils import regex user_path = r'^user/(?P%s)' % regex.username @@ -29,8 +29,8 @@ urlpatterns = [ path('admin/', admin.site.urls), # federation endpoints - re_path(r'^inbox/?$', incoming.shared_inbox), - re_path(r'%s/inbox/?$' % local_user_path, incoming.inbox), + re_path(r'^inbox/?$', views.Inbox.as_view()), + re_path(r'%s/inbox/?$' % local_user_path, views.Inbox.as_view()), re_path(r'%s/outbox/?$' % local_user_path, views.Outbox.as_view()), re_path(r'^.well-known/webfinger/?$', wellknown.webfinger), re_path(r'^.well-known/nodeinfo/?$', wellknown.nodeinfo_pointer), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index b72c5013..2c7cdc46 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -11,6 +11,7 @@ from .follow import follow, unfollow from .follow import accept_follow_request, delete_follow_request from .goal import Goal from .import_data import Import, ImportStatus +from .inbox import Inbox from .interaction import Favorite, Unfavorite, Boost, Unboost from .invite import ManageInvites, Invite from .landing import About, Home, Discover diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 9d196563..e05c4726 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -45,15 +45,9 @@ class Book(View): if not work: return HttpResponseNotFound() - reviews = models.Review.objects.filter( - book__in=work.editions.all(), - ) # all reviews for the book - reviews = get_activity_feed( - request.user, - ['public', 'unlisted', 'followers', 'direct'], - queryset=reviews - ) + reviews = models.Review.objects.filter(book__in=work.editions.all()) + reviews = get_activity_feed(request.user, queryset=reviews) # the reviews to show paginated = Paginator(reviews.exclude( @@ -96,9 +90,8 @@ class Book(View): 'rating': reviews.aggregate(Avg('rating'))['rating__avg'], 'tags': models.UserTag.objects.filter(book=book), 'lists': privacy_filter( - request.user, - book.list_set.all(), - ['public', 'unlisted', 'followers']), + request.user, book.list_set.all() + ), 'user_tags': user_tags, 'user_shelves': user_shelves, 'other_edition_shelves': other_edition_shelves, diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py index 0e550f0c..3a2805b4 100644 --- a/bookwyrm/views/feed.py +++ b/bookwyrm/views/feed.py @@ -11,9 +11,8 @@ from django.views import View from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH -from .helpers import get_activity_feed -from .helpers import get_user_from_username -from .helpers import is_api_request, is_bookworm_request, object_visible_to_user +from .helpers import get_activity_feed, get_user_from_username +from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user # pylint: disable= no-self-use @@ -29,14 +28,13 @@ class Feed(View): if tab == 'home': activities = get_activity_feed( - request.user, ['public', 'unlisted', 'followers'], - following_only=True) + request.user, following_only=True) elif tab == 'local': activities = get_activity_feed( - request.user, ['public', 'followers'], local_only=True) + request.user, privacy=['public', 'followers'], local_only=True) else: activities = get_activity_feed( - request.user, ['public', 'followers']) + request.user, privacy=['public', 'followers']) paginated = Paginator(activities, PAGE_LENGTH) data = {**feed_page_data(request.user), **{ @@ -65,14 +63,14 @@ class DirectMessage(View): user = None if username: try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) except models.User.DoesNotExist: pass if user: queryset = queryset.filter(Q(user=user) | Q(mention_users=user)) activities = get_activity_feed( - request.user, 'direct', queryset=queryset) + request.user, privacy=['direct'], queryset=queryset) paginated = Paginator(activities, PAGE_LENGTH) activity_page = paginated.page(page) @@ -91,7 +89,7 @@ class Status(View): def get(self, request, username, status_id): ''' display a particular status (and replies, etc) ''' try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) status = models.Status.objects.select_subclasses().get( id=status_id, deleted=False) except ValueError: @@ -107,7 +105,7 @@ class Status(View): if is_api_request(request): 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), **{ 'title': 'Status by %s' % user.username, diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py index c59f2e6d..4c69890c 100644 --- a/bookwyrm/views/follow.py +++ b/bookwyrm/views/follow.py @@ -1,5 +1,6 @@ ''' views for actions you can take in the application ''' from django.contrib.auth.decorators import login_required +from django.db import IntegrityError from django.http import HttpResponseBadRequest from django.shortcuts import redirect from django.views.decorators.http import require_POST @@ -13,17 +14,18 @@ def follow(request): ''' follow another user, here or abroad ''' username = request.POST['user'] try: - to_follow = get_user_from_username(username) + to_follow = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseBadRequest() - rel, _ = models.UserFollowRequest.objects.get_or_create( - user_subject=request.user, - user_object=to_follow, - ) + try: + models.UserFollowRequest.objects.create( + user_subject=request.user, + user_object=to_follow, + ) + except IntegrityError: + pass - if to_follow.local and not to_follow.manually_approves_followers: - rel.accept() return redirect(to_follow.local_path) @@ -33,16 +35,14 @@ def unfollow(request): ''' unfollow a user ''' username = request.POST['user'] try: - to_unfollow = get_user_from_username(username) + to_unfollow = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseBadRequest() models.UserFollows.objects.get( user_subject=request.user, user_object=to_unfollow - ) - - to_unfollow.followers.remove(request.user) + ).delete() return redirect(to_unfollow.local_path) @@ -52,7 +52,7 @@ def accept_follow_request(request): ''' a user accepts a follow request ''' username = request.POST['user'] try: - requester = get_user_from_username(username) + requester = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseBadRequest() @@ -75,7 +75,7 @@ def delete_follow_request(request): ''' a user rejects a follow request ''' username = request.POST['user'] try: - requester = get_user_from_username(username) + requester = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseBadRequest() diff --git a/bookwyrm/views/goal.py b/bookwyrm/views/goal.py index 4f2d1b6f..97f13913 100644 --- a/bookwyrm/views/goal.py +++ b/bookwyrm/views/goal.py @@ -18,7 +18,7 @@ class Goal(View): ''' track books for the year ''' def get(self, request, username, year): ''' reading goal page ''' - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) year = int(year) goal = models.AnnualGoal.objects.filter( year=year, user=user @@ -42,7 +42,7 @@ class Goal(View): def post(self, request, username, year): ''' update or create an annual goal ''' - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) if user != request.user: return HttpResponseNotFound() diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 842b8d1c..64f0fc26 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -1,6 +1,7 @@ ''' helper functions used in various views ''' import re from requests import HTTPError +from django.core.exceptions import FieldError from django.db.models import Q from bookwyrm import activitypub, models @@ -9,13 +10,13 @@ from bookwyrm.status import create_generated_note 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 ''' # raises DoesNotExist if user is now found try: - return models.User.objects.get(localname=username) + return models.User.viewer_aware_objects(viewer).get(localname=username) 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): @@ -24,8 +25,8 @@ def is_api_request(request): request.path[-5:] == '.json' -def is_bookworm_request(request): - ''' check if the request is coming from another bookworm instance ''' +def is_bookwyrm_request(request): + ''' check if the request is coming from another bookwyrm instance ''' user_agent = request.headers.get('User-Agent') if user_agent is None or \ re.search(regex.bookwyrm_user_agent, user_agent) is None: @@ -59,8 +60,11 @@ def object_visible_to_user(viewer, obj): 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 ''' + privacy_levels = privacy_levels or \ + ['public', 'unlisted', 'followers', 'direct'] + # exclude blocks from both directions if not viewer.is_anonymous: 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 if 'direct' in privacy_levels: - queryset = queryset.exclude( - ~Q( - Q(user=viewer) | Q(mention_users=viewer) - ), privacy='direct' - ) + try: + queryset = queryset.exclude( + ~Q( + Q(user=viewer) | Q(mention_users=viewer) + ), privacy='direct' + ) + except FieldError: + queryset = queryset.exclude( + ~Q(user=viewer), privacy='direct' + ) + return queryset def get_activity_feed( - user, privacy, local_only=False, following_only=False, - queryset=models.Status.objects): + user, privacy=None, local_only=False, following_only=False, + queryset=None): ''' get a filtered queryset of statuses ''' - # if we're looking at Status, we need this. We don't if it's Comment - if hasattr(queryset, 'select_subclasses'): - queryset = queryset.select_subclasses() + if queryset is None: + queryset = models.Status.objects.select_subclasses() # exclude deleted queryset = queryset.exclude(deleted=True).order_by('-published_date') # apply privacy filters - privacy = privacy if isinstance(privacy, list) else [privacy] queryset = privacy_filter( 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 if local_only: queryset = queryset.filter(user__local=True) @@ -162,7 +192,7 @@ def handle_remote_webfinger(query): if link.get('rel') == 'self': try: user = activitypub.resolve_remote_id( - models.User, link['href'] + link['href'], model=models.User ) except KeyError: return None diff --git a/bookwyrm/views/inbox.py b/bookwyrm/views/inbox.py new file mode 100644 index 00000000..4da4e5b6 --- /dev/null +++ b/bookwyrm/views/inbox.py @@ -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 diff --git a/bookwyrm/views/invite.py b/bookwyrm/views/invite.py index bd0715a0..6b3611fc 100644 --- a/bookwyrm/views/invite.py +++ b/bookwyrm/views/invite.py @@ -1,5 +1,6 @@ ''' invites when registration is closed ''' from django.contrib.auth.decorators import login_required, permission_required +from django.core.paginator import Paginator from django.http import HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse @@ -7,6 +8,7 @@ from django.utils.decorators import method_decorator from django.views import View from bookwyrm import forms, models +from bookwyrm.settings import PAGE_LENGTH # pylint: disable= no-self-use @@ -18,10 +20,18 @@ class ManageInvites(View): ''' create invites ''' def get(self, request): ''' 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 = { 'title': 'Invitations', - 'invites': models.SiteInvite.objects.filter( - user=request.user).order_by('-created_date'), + 'invites': paginated.page(page), 'form': forms.CreateInviteForm(), } return TemplateResponse(request, 'settings/manage_invites.html', data) @@ -36,7 +46,15 @@ class ManageInvites(View): invite.user = request.user 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): diff --git a/bookwyrm/views/landing.py b/bookwyrm/views/landing.py index 0d841ef0..2774e742 100644 --- a/bookwyrm/views/landing.py +++ b/bookwyrm/views/landing.py @@ -1,11 +1,10 @@ ''' non-interactive pages ''' -from django.db.models import Avg, Max +from django.db.models import Max from django.template.response import TemplateResponse from django.views import View from bookwyrm import forms, models from .feed import Feed -from .helpers import get_activity_feed # pylint: disable= no-self-use @@ -16,7 +15,7 @@ class About(View): data = { 'title': 'About', } - return TemplateResponse(request, 'about.html', data) + return TemplateResponse(request, 'discover/about.html', data) class Home(View): ''' discover page or home feed depending on auth ''' @@ -34,6 +33,7 @@ class Discover(View): ''' tiled book activity page ''' books = models.Edition.objects.filter( review__published_date__isnull=False, + review__deleted=False, review__user__local=True, review__privacy__in=['public', 'unlisted'], ).exclude( @@ -42,18 +42,9 @@ class Discover(View): Max('review__published_date') ).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 = { 'title': 'Discover', 'register_form': forms.RegisterForm(), 'books': list(set(books)), - 'ratings': ratings } - return TemplateResponse(request, 'discover.html', data) + return TemplateResponse(request, 'discover/discover.html', data) diff --git a/bookwyrm/views/list.py b/bookwyrm/views/list.py index cfdf6d76..e7b70a28 100644 --- a/bookwyrm/views/list.py +++ b/bookwyrm/views/list.py @@ -35,7 +35,8 @@ class Lists(View): ).filter( item_count__gt=0 ).distinct().all() - lists = privacy_filter(request.user, lists, ['public', 'followers']) + lists = privacy_filter( + request.user, lists, privacy_levels=['public', 'followers']) paginated = Paginator(lists, 12) data = { @@ -65,10 +66,9 @@ class UserLists(View): page = int(request.GET.get('page', 1)) except ValueError: 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 = privacy_filter( - request.user, lists, ['public', 'followers', 'unlisted']) + lists = privacy_filter(request.user, lists) paginated = Paginator(lists, 12) data = { diff --git a/bookwyrm/views/outbox.py b/bookwyrm/views/outbox.py index 8bfc3b3d..5df9d199 100644 --- a/bookwyrm/views/outbox.py +++ b/bookwyrm/views/outbox.py @@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404 from django.views import View from bookwyrm import activitypub, models +from .helpers import is_bookwyrm_request # pylint: disable= no-self-use @@ -17,6 +18,10 @@ class Outbox(View): filter_type = None 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 ) diff --git a/bookwyrm/views/rss_feed.py b/bookwyrm/views/rss_feed.py index 496689ff..d24b636e 100644 --- a/bookwyrm/views/rss_feed.py +++ b/bookwyrm/views/rss_feed.py @@ -11,7 +11,7 @@ class RssFeed(Feed): def get_object(self, request, username): ''' 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): @@ -27,7 +27,10 @@ class RssFeed(Feed): def items(self, obj): ''' the user's 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): diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py index a4cd7337..8acb2836 100644 --- a/bookwyrm/views/search.py +++ b/bookwyrm/views/search.py @@ -33,7 +33,7 @@ class Search(View): handle_remote_webfinger(query) # do a user search - user_results = models.User.objects.annotate( + user_results = models.User.viewer_aware_objects(request.user).annotate( similarity=Greatest( TrigramSimilarity('username', query), TrigramSimilarity('localname', query), @@ -44,7 +44,8 @@ class Search(View): # any relevent lists? list_results = privacy_filter( - request.user, models.List.objects, ['public', 'followers'] + request.user, models.List.objects, + privacy_levels=['public', 'followers'] ).annotate( similarity=Greatest( TrigramSimilarity('name', query), diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py index 02502ff6..70d3d1de 100644 --- a/bookwyrm/views/shelf.py +++ b/bookwyrm/views/shelf.py @@ -19,7 +19,7 @@ class Shelf(View): def get(self, request, username, shelf_identifier): ''' display a shelf ''' try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseNotFound() diff --git a/bookwyrm/views/tag.py b/bookwyrm/views/tag.py index b50bc0ef..502f5ea5 100644 --- a/bookwyrm/views/tag.py +++ b/bookwyrm/views/tag.py @@ -1,6 +1,5 @@ ''' tagging views''' from django.contrib.auth.decorators import login_required -from django.http import HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.utils.decorators import method_decorator @@ -16,12 +15,11 @@ class Tag(View): ''' tag page ''' def get(self, request, tag_id): ''' see books related to a tag ''' - tag_obj = models.Tag.objects.filter(identifier=tag_id).first() - if not tag_obj: - return HttpResponseNotFound() + tag_obj = get_object_or_404(models.Tag, identifier=tag_id) if is_api_request(request): - return ActivitypubResponse(tag_obj.to_activity(**request.GET)) + return ActivitypubResponse( + tag_obj.to_activity(**request.GET)) books = models.Edition.objects.filter( usertag__tag__identifier=tag_id diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index 4da0fdac..a218375f 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -26,7 +26,7 @@ class User(View): def get(self, request, username): ''' profile page for a user ''' try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseNotFound() @@ -71,8 +71,7 @@ class User(View): # user's posts activities = get_activity_feed( request.user, - ['public', 'unlisted', 'followers'], - queryset=user.status_set + queryset=user.status_set.select_subclasses(), ) paginated = Paginator(activities, PAGE_LENGTH) goal = models.AnnualGoal.objects.filter( @@ -96,7 +95,7 @@ class Followers(View): def get(self, request, username): ''' list of followers ''' try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseNotFound() @@ -121,7 +120,7 @@ class Following(View): def get(self, request, username): ''' list of followers ''' try: - user = get_user_from_username(username) + user = get_user_from_username(request.user, username) except models.User.DoesNotExist: return HttpResponseNotFound() diff --git a/celerywyrm/celery.py b/celerywyrm/celery.py index 5a53dab5..2937ef0f 100644 --- a/celerywyrm/celery.py +++ b/celerywyrm/celery.py @@ -25,5 +25,5 @@ app.autodiscover_tasks( ['bookwyrm'], related_name='connectors.abstract_connector') app.autodiscover_tasks(['bookwyrm'], related_name='emailing') app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import') -app.autodiscover_tasks(['bookwyrm'], related_name='incoming') app.autodiscover_tasks(['bookwyrm'], related_name='models.user') +app.autodiscover_tasks(['bookwyrm'], related_name='views.inbox')