diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index a4fef41e..a7439722 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -14,7 +14,7 @@ from .person import Person, PublicKey from .response import ActivitypubResponse from .book import Edition, Work, Author from .verbs import Create, Delete, Undo, Update -from .verbs import Follow, Accept, Reject +from .verbs import Follow, Accept, Reject, Block from .verbs import Add, AddBook, Remove # this creates a list of all the Activity types that we can serialize, diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 7c627927..6977ee8e 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -48,6 +48,10 @@ class Follow(Verb): ''' Follow activity ''' type: str = 'Follow' +@dataclass(init=False) +class Block(Verb): + ''' Block activity ''' + type: str = 'Block' @dataclass(init=False) class Accept(Verb): diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 9653c5d2..1e42d32a 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -51,6 +51,7 @@ def shared_inbox(request): 'Follow': handle_follow, 'Accept': handle_follow_accept, 'Reject': handle_follow_reject, + 'Block': handle_block, 'Create': handle_create, 'Delete': handle_delete_status, 'Like': handle_favorite, @@ -62,6 +63,7 @@ def shared_inbox(request): 'Follow': handle_unfollow, 'Like': handle_unfavorite, 'Announce': handle_unboost, + 'Block': handle_unblock, }, 'Update': { 'Person': handle_update_user, @@ -179,6 +181,27 @@ def handle_follow_reject(activity): 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(activity): diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 0f3c1dab..ec84d44f 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,5 +1,7 @@ ''' defines relationships between users ''' from django.db import models +from django.db.models import Q +from django.dispatch import receiver from bookwyrm import activitypub from .base_model import ActivitypubMixin, BookWyrmModel @@ -94,5 +96,23 @@ class UserFollowRequest(UserRelationship): class UserBlocks(UserRelationship): ''' prevent another user from following you and seeing your posts ''' - # TODO: not implemented status = 'blocks' + activity_serializer = activitypub.Block + + +@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() diff --git a/bookwyrm/templates/blocks.html b/bookwyrm/templates/blocks.html new file mode 100644 index 00000000..1df49816 --- /dev/null +++ b/bookwyrm/templates/blocks.html @@ -0,0 +1,24 @@ +{% extends 'preferences_layout.html' %} + +{% block header %} +Blocked Users +{% endblock %} + +{% block panel %} +{% if not request.user.blocks.exists %} +

No users currently blocked.

+{% else %} + +{% endif %} +{% endblock %} diff --git a/bookwyrm/templates/change_password.html b/bookwyrm/templates/change_password.html new file mode 100644 index 00000000..c373dfc8 --- /dev/null +++ b/bookwyrm/templates/change_password.html @@ -0,0 +1,19 @@ +{% extends 'preferences_layout.html' %} +{% block header %} +Change Password +{% endblock %} + +{% block panel %} +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+{% endblock %} diff --git a/bookwyrm/templates/edit_user.html b/bookwyrm/templates/edit_user.html index 413f2cae..ee9ddb22 100644 --- a/bookwyrm/templates/edit_user.html +++ b/bookwyrm/templates/edit_user.html @@ -1,66 +1,48 @@ -{% extends 'layout.html' %} -{% block content %} -
-
-

Profile

- {% if form.non_field_errors %} -

{{ form.non_field_errors }}

- {% endif %} -
- {% csrf_token %} -
- - {{ form.avatar }} - {% for error in form.avatar.errors %} -

{{ error | escape }}

- {% endfor %} -
-
- - {{ form.name }} - {% for error in form.name.errors %} -

{{ error | escape }}

- {% endfor %} -
-
- - {{ form.summary }} - {% for error in form.summary.errors %} -

{{ error | escape }}

- {% endfor %} -
-
- - {{ form.email }} - {% for error in form.email.errors %} -

{{ error | escape }}

- {% endfor %} -
-
- -
- -
-
-
-
-

Change password

-
- {% csrf_token %} -
- - -
-
- - -
- -
-
-
-
+{% extends 'preferences_layout.html' %} +{% block header %} +Edit Profile +{% endblock %} + +{% block panel %} +{% if form.non_field_errors %} +

{{ form.non_field_errors }}

+{% endif %} +
+ {% csrf_token %} +
+ + {{ form.avatar }} + {% for error in form.avatar.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ + {{ form.name }} + {% for error in form.name.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ + {{ form.summary }} + {% for error in form.summary.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ + {{ form.email }} + {% for error in form.email.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ +
+ +
{% endblock %} diff --git a/bookwyrm/templates/preferences_layout.html b/bookwyrm/templates/preferences_layout.html new file mode 100644 index 00000000..de2fe0df --- /dev/null +++ b/bookwyrm/templates/preferences_layout.html @@ -0,0 +1,31 @@ +{% extends 'layout.html' %} +{% block content %} + +
+

{% block header %}{% endblock %}

+
+ +
+ +
+ {% block panel %}{% endblock %} +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/snippets/block_button.html b/bookwyrm/templates/snippets/block_button.html new file mode 100644 index 00000000..9e49254d --- /dev/null +++ b/bookwyrm/templates/snippets/block_button.html @@ -0,0 +1,11 @@ +{% if not user in request.user.blocks.all %} +
+ {% csrf_token %} + +
+{% else %} +
+ {% csrf_token %} + +
+{% endif %} diff --git a/bookwyrm/templates/snippets/status_body.html b/bookwyrm/templates/snippets/status_body.html index 59ab234d..c70f898a 100644 --- a/bookwyrm/templates/snippets/status_body.html +++ b/bookwyrm/templates/snippets/status_body.html @@ -54,11 +54,9 @@ -{% if status.user == request.user %} -{% endif %} {% endblock %} diff --git a/bookwyrm/templates/snippets/status_options.html b/bookwyrm/templates/snippets/status_options.html index 6cd13dfd..b5887b1d 100644 --- a/bookwyrm/templates/snippets/status_options.html +++ b/bookwyrm/templates/snippets/status_options.html @@ -1,4 +1,5 @@ {% extends 'snippets/components/dropdown.html' %} +{% load bookwyrm_tags %} {% block dropdown-trigger %} @@ -7,6 +8,7 @@ {% endblock %} {% block dropdown-list %} +{% if status.user == request.user %}
  • +{% else %} +
  • + {% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %} +
  • +{% endif %} {% endblock %} diff --git a/bookwyrm/templates/snippets/user_header.html b/bookwyrm/templates/snippets/user_header.html index a528bb1c..8f5e264a 100644 --- a/bookwyrm/templates/snippets/user_header.html +++ b/bookwyrm/templates/snippets/user_header.html @@ -35,7 +35,14 @@ {% if not is_self %} - {% include 'snippets/follow_button.html' with user=user %} +
    +
    + {% include 'snippets/follow_button.html' with user=user %} +
    +
    + {% include 'snippets/user_options.html' with user=user class="is-small" %} +
    +
    {% endif %} {% if is_self and user.follower_requests.all %} diff --git a/bookwyrm/templates/snippets/user_options.html b/bookwyrm/templates/snippets/user_options.html new file mode 100644 index 00000000..2c163034 --- /dev/null +++ b/bookwyrm/templates/snippets/user_options.html @@ -0,0 +1,14 @@ +{% extends 'snippets/components/dropdown.html' %} +{% load bookwyrm_tags %} + +{% block dropdown-trigger %} + + More options + +{% endblock %} + +{% block dropdown-list %} +
  • + {% include 'snippets/block_button.html' with user=user class="is-fullwidth" %} +
  • +{% endblock %} diff --git a/bookwyrm/templates/user.html b/bookwyrm/templates/user.html index a41623a8..69b762b0 100644 --- a/bookwyrm/templates/user.html +++ b/bookwyrm/templates/user.html @@ -17,7 +17,7 @@ {% include 'snippets/user_header.html' with user=user %} - +{% if user.bookwyrm_user %}

    Shelves

    @@ -39,6 +39,7 @@
    See all {{ shelf_count }} shelves
    +{% endif %} {% if goal %}
    diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 2cd4869e..1ee7c59e 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -540,3 +540,46 @@ class Incoming(TestCase): incoming.handle_update_work({'object': bookdata}) book = models.Work.objects.get(id=book.id) self.assertEqual(book.title, 'Piranesi') + + + def test_handle_blocks(self): + ''' create a "block" database entry from an activity ''' + self.local_user.followers.add(self.remote_user) + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user) + self.assertTrue(models.UserFollows.objects.exists()) + self.assertTrue(models.UserFollowRequest.objects.exists()) + + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/9e1f41ac-9ddd-4159-aede-9f43c6b9314f", + "type": "Block", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse" + } + + incoming.handle_block(activity) + block = models.UserBlocks.objects.get() + self.assertEqual(block.user_subject, self.remote_user) + self.assertEqual(block.user_object, self.local_user) + + self.assertFalse(models.UserFollows.objects.exists()) + self.assertFalse(models.UserFollowRequest.objects.exists()) + + def test_handle_unblock(self): + ''' undoing a block ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://friend.camp/users/tripofmice#blocks/1155/undo", + "type": "Undo", + "actor": "https://friend.camp/users/tripofmice", + "object": { + "id": "https://friend.camp/0a7d85f7-6359-4c03-8ab6-74e61a8fb678", + "type": "Block", + "actor": "https://friend.camp/users/tripofmice", + "object": "https://1b1a78582461.ngrok.io/user/mouse" + } + } + + self.remote_user.blocks.add(self.local_user) diff --git a/bookwyrm/tests/views/test_authentication.py b/bookwyrm/tests/views/test_authentication.py index b0d09983..65577208 100644 --- a/bookwyrm/tests/views/test_authentication.py +++ b/bookwyrm/tests/views/test_authentication.py @@ -42,83 +42,6 @@ class AuthenticationViews(TestCase): self.assertEqual(result.status_code, 302) - def test_password_reset_request(self): - ''' there are so many views, this just makes sure it LOADS ''' - view = views.PasswordResetRequest.as_view() - request = self.factory.get('') - request.user = self.local_user - - result = view(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset_request.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset_request_post(self): - ''' send 'em an email ''' - request = self.factory.post('', {'email': 'aa@bb.ccc'}) - view = views.PasswordResetRequest.as_view() - resp = view(request) - self.assertEqual(resp.status_code, 302) - - request = self.factory.post('', {'email': 'mouse@mouse.com'}) - with patch('bookwyrm.emailing.send_email.delay'): - resp = view(request) - self.assertEqual(resp.template_name, 'password_reset_request.html') - - self.assertEqual( - models.PasswordReset.objects.get().user, self.local_user) - - def test_password_reset(self): - ''' there are so many views, this just makes sure it LOADS ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.get('') - request.user = self.anonymous_user - result = view(request, code.code) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset_post(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - with patch('bookwyrm.views.password.login'): - resp = view(request, code.code) - self.assertEqual(resp.status_code, 302) - self.assertFalse(models.PasswordReset.objects.exists()) - - def test_password_reset_wrong_code(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - resp = view(request, 'jhgdkfjgdf') - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - def test_password_reset_mismatch(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hihi' - }) - resp = view(request, code.code) - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - def test_register(self): ''' create a user ''' view = views.Register.as_view() @@ -274,29 +197,3 @@ class AuthenticationViews(TestCase): with self.assertRaises(Http404): response = view(request) self.assertEqual(models.User.objects.count(), 2) - - - def test_password_change(self): - ''' change password ''' - view = views.ChangePassword.as_view() - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - request.user = self.local_user - with patch('bookwyrm.views.password.login'): - view(request) - self.assertNotEqual(self.local_user.password, password_hash) - - def test_password_change_mismatch(self): - ''' change password ''' - view = views.ChangePassword.as_view() - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hihi' - }) - request.user = self.local_user - view(request) - self.assertEqual(self.local_user.password, password_hash) diff --git a/bookwyrm/tests/views/test_block.py b/bookwyrm/tests/views/test_block.py new file mode 100644 index 00000000..fa5254d1 --- /dev/null +++ b/bookwyrm/tests/views/test_block.py @@ -0,0 +1,68 @@ +''' test for app action functionality ''' +from unittest.mock import patch +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +class BlockViews(TestCase): + ''' view user and edit profile ''' + def setUp(self): + ''' we need basic test data and mocks ''' + self.factory = RequestFactory() + self.local_user = models.User.objects.create_user( + 'mouse@local.com', 'mouse@mouse.mouse', 'password', + local=True, localname='mouse') + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + + + def test_block_get(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Block.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'blocks.html') + self.assertEqual(result.status_code, 200) + + def test_block_post(self): + ''' create a "block" database entry from an activity ''' + view = views.Block.as_view() + self.local_user.followers.add(self.remote_user) + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user) + self.assertTrue(models.UserFollows.objects.exists()) + self.assertTrue(models.UserFollowRequest.objects.exists()) + + request = self.factory.post('') + request.user = self.local_user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, self.remote_user.id) + block = models.UserBlocks.objects.get() + self.assertEqual(block.user_subject, self.local_user) + self.assertEqual(block.user_object, self.remote_user) + + self.assertFalse(models.UserFollows.objects.exists()) + self.assertFalse(models.UserFollowRequest.objects.exists()) + + def test_unblock(self): + ''' undo a block ''' + self.local_user.blocks.add(self.remote_user) + request = self.factory.post('') + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.block.unblock(request, self.remote_user.id) + + self.assertFalse(models.UserBlocks.objects.exists()) diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index bd892896..50c3cfc5 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -154,6 +154,34 @@ class ViewsHelpers(TestCase): self.assertEqual(statuses[0], rat_mention) + def test_get_activity_feed_blocks(self): + ''' feed generation with blocked users ''' + rat = models.User.objects.create_user( + 'rat', 'rat@rat.rat', 'password', local=True) + + public_status = models.Comment.objects.create( + content='public status', book=self.book, user=self.local_user) + rat_public = models.Status.objects.create( + content='blah blah', user=rat) + + statuses = views.helpers.get_activity_feed( + self.local_user, ['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.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], public_status) + + statuses = views.helpers.get_activity_feed( + rat, ['public']) + self.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], rat_public) + + + def test_is_bookwyrm_request(self): ''' checks if a request came from a bookwyrm instance ''' request = self.factory.get('', {'q': 'Test Book'}) @@ -248,3 +276,63 @@ class ViewsHelpers(TestCase): views.helpers.handle_reading_status( self.local_user, self.shelf, self.book, 'public') self.assertFalse(models.GeneratedNote.objects.exists()) + + def test_object_visible_to_user(self): + ''' does a user have permission to view an object ''' + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='public') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Shelf.objects.create( + name='test', user=self.remote_user, privacy='unlisted') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='followers') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + obj.mention_users.add(self.local_user) + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + def test_object_visible_to_user_follower(self): + ''' what you can see if you follow a user ''' + self.remote_user.followers.add(self.local_user) + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='followers') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + obj.mention_users.add(self.local_user) + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + def test_object_visible_to_user_blocked(self): + ''' you can't see it if they block you ''' + self.remote_user.blocks.add(self.local_user) + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='public') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Shelf.objects.create( + name='test', user=self.remote_user, privacy='unlisted') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) diff --git a/bookwyrm/tests/views/test_password.py b/bookwyrm/tests/views/test_password.py new file mode 100644 index 00000000..0f9c8988 --- /dev/null +++ b/bookwyrm/tests/views/test_password.py @@ -0,0 +1,136 @@ +''' test for app action functionality ''' +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +class PasswordViews(TestCase): + ''' view user and edit profile ''' + def setUp(self): + ''' we need basic test data and mocks ''' + self.factory = RequestFactory() + self.local_user = models.User.objects.create_user( + 'mouse@local.com', 'mouse@mouse.com', 'password', + local=True, localname='mouse') + self.anonymous_user = AnonymousUser + self.anonymous_user.is_authenticated = False + + + def test_password_reset_request(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordResetRequest.as_view() + request = self.factory.get('') + request.user = self.local_user + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset_request.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_request_post(self): + ''' send 'em an email ''' + request = self.factory.post('', {'email': 'aa@bb.ccc'}) + view = views.PasswordResetRequest.as_view() + resp = view(request) + self.assertEqual(resp.status_code, 302) + + request = self.factory.post('', {'email': 'mouse@mouse.com'}) + with patch('bookwyrm.emailing.send_email.delay'): + resp = view(request) + self.assertEqual(resp.template_name, 'password_reset_request.html') + + self.assertEqual( + models.PasswordReset.objects.get().user, self.local_user) + + def test_password_reset(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.get('') + request.user = self.anonymous_user + result = view(request, code.code) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_post(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + with patch('bookwyrm.views.password.login'): + resp = view(request, code.code) + self.assertEqual(resp.status_code, 302) + self.assertFalse(models.PasswordReset.objects.exists()) + + def test_password_reset_wrong_code(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + resp = view(request, 'jhgdkfjgdf') + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + def test_password_reset_mismatch(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + resp = view(request, code.code) + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + + def test_password_change_get(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.ChangePassword.as_view() + request = self.factory.get('') + request.user = self.local_user + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'change_password.html') + self.assertEqual(result.status_code, 200) + + + def test_password_change(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + request.user = self.local_user + with patch('bookwyrm.views.password.login'): + view(request) + self.assertNotEqual(self.local_user.password, password_hash) + + def test_password_change_mismatch(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + request.user = self.local_user + view(request) + self.assertEqual(self.local_user.password, password_hash) diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py index 6a51aeda..07acc4c4 100644 --- a/bookwyrm/tests/views/test_user.py +++ b/bookwyrm/tests/views/test_user.py @@ -20,6 +20,9 @@ class UserViews(TestCase): self.local_user = models.User.objects.create_user( 'mouse@local.com', 'mouse@mouse.mouse', 'password', local=True, localname='mouse') + self.rat = models.User.objects.create_user( + 'rat@local.com', 'rat@rat.rat', 'password', + local=True, localname='rat') def test_user_page(self): @@ -41,6 +44,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_user_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.User.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_followers_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.Followers.as_view() @@ -60,6 +75,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_followers_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Followers.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_following_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.Following.as_view() @@ -79,6 +106,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_following_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Following.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_edit_profile_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.EditUser.as_view() diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 4da0c0c1..4f9a43ea 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -47,7 +47,7 @@ urlpatterns = [ re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()), re_path(r'^password-reset/(?P[A-Za-z0-9]+)/?$', views.PasswordReset.as_view()), - re_path(r'^change-password/?$', views.ChangePassword), + re_path(r'^change-password/?$', views.ChangePassword.as_view()), # invites re_path(r'^invite/?$', views.ManageInvites.as_view()), @@ -136,4 +136,8 @@ urlpatterns = [ re_path(r'^unfollow/?$', views.unfollow), re_path(r'^accept-follow-request/?$', views.accept_follow_request), re_path(r'^delete-follow-request/?$', views.delete_follow_request), + + re_path(r'^block/?$', views.Block.as_view()), + re_path(r'^block/(?P\d+)/?$', views.Block.as_view()), + re_path(r'^unblock/(?P\d+)/?$', views.unblock), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index b9c26388..e3ac29c8 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -1,6 +1,7 @@ ''' make sure all our nice views are available ''' from .authentication import Login, Register, Logout from .author import Author, EditAuthor +from .block import Block, unblock from .books import Book, EditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .direct_message import DirectMessage diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py new file mode 100644 index 00000000..fb95479a --- /dev/null +++ b/bookwyrm/views/block.py @@ -0,0 +1,58 @@ +''' views for actions you can take in the application ''' +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 +from django.views import View +from django.views.decorators.http import require_POST + +from bookwyrm import models +from bookwyrm.broadcast import broadcast + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class Block(View): + ''' blocking users ''' + def get(self, request): + ''' list of blocked users? ''' + return TemplateResponse( + request, 'blocks.html', {'title': 'Blocked Users'}) + + def post(self, request, user_id): + ''' block a user ''' + to_block = get_object_or_404(models.User, id=user_id) + block = models.UserBlocks.objects.create( + user_subject=request.user, user_object=to_block) + if not to_block.local: + broadcast( + request.user, + block.to_activity(), + privacy='direct', + direct_recipients=[to_block] + ) + return redirect('/block') + + +@require_POST +@login_required +def unblock(request, user_id): + ''' undo a block ''' + to_unblock = get_object_or_404(models.User, id=user_id) + try: + block = models.UserBlocks.objects.get( + user_subject=request.user, + user_object=to_unblock, + ) + except models.UserBlocks.DoesNotExist: + return HttpResponseNotFound() + + if not to_unblock.local: + broadcast( + request.user, + block.to_undo_activity(request.user), + privacy='direct', + direct_recipients=[to_unblock] + ) + block.delete() + return redirect('/block') diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 60159324..6bda81c8 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -38,11 +38,21 @@ def object_visible_to_user(viewer, obj): ''' is a user authorized to view an object? ''' if not obj: return False + + # viewer can't see it if the object's owner blocked them + if viewer in obj.user.blocks.all(): + return False + + # you can see your own posts and any public or unlisted posts if viewer == obj.user or obj.privacy in ['public', 'unlisted']: return True + + # you can see the followers only posts of people you follow if obj.privacy == 'followers' and \ obj.user.followers.filter(id=viewer.id).first(): return True + + # you can see dms you are tagged in if isinstance(obj, models.Status): if obj.privacy == 'direct' and \ obj.mention_users.filter(id=viewer.id).first(): @@ -61,6 +71,12 @@ def get_activity_feed( # exclude deleted queryset = queryset.exclude(deleted=True).order_by('-published_date') + # exclude blocks from both directions + if not user.is_anonymous: + blocked = models.User.objects.filter(id__in=user.blocks.all()).all() + queryset = queryset.exclude( + Q(user__in=blocked) | Q(user__blocks=user)) + # you can't see followers only or direct messages if you're not logged in if user.is_anonymous: privacy = [p for p in privacy if not p in ['followers', 'direct']] @@ -174,3 +190,9 @@ def handle_reading_status(user, shelf, book, privacy): status.save() broadcast(user, status.to_create_activity(user)) + +def is_blocked(viewer, user): + ''' is this viewer blocked by the user? ''' + if viewer.is_authenticated and viewer in user.blocks.all(): + return True + return False diff --git a/bookwyrm/views/password.py b/bookwyrm/views/password.py index 915659e3..06ddc1da 100644 --- a/bookwyrm/views/password.py +++ b/bookwyrm/views/password.py @@ -88,6 +88,14 @@ class PasswordReset(View): @method_decorator(login_required, name='dispatch') class ChangePassword(View): ''' change password as logged in user ''' + def get(self, request): + ''' change password page ''' + data = { + 'title': 'Change Password', + 'user': request.user, + } + return TemplateResponse(request, 'change_password.html', data) + def post(self, request): ''' allow a user to change their password ''' new_password = request.POST.get('password') diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index b65fb48f..668ef205 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -18,7 +18,7 @@ from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.broadcast import broadcast from bookwyrm.settings import PAGE_LENGTH from .helpers import get_activity_feed, get_user_from_username, is_api_request -from .helpers import object_visible_to_user +from .helpers import is_blocked, object_visible_to_user # pylint: disable= no-self-use @@ -31,6 +31,10 @@ class User(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if is_blocked(request.user, user): + return HttpResponseNotFound() + if is_api_request(request): # we have a json request return ActivitypubResponse(user.to_activity()) @@ -97,6 +101,10 @@ class Followers(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if is_blocked(request.user, user): + return HttpResponseNotFound() + if is_api_request(request): return ActivitypubResponse( user.to_followers_activity(**request.GET)) @@ -118,6 +126,10 @@ class Following(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if is_blocked(request.user, user): + return HttpResponseNotFound() + if is_api_request(request): return ActivitypubResponse( user.to_following_activity(**request.GET)) @@ -135,14 +147,11 @@ class Following(View): class EditUser(View): ''' edit user view ''' def get(self, request): - ''' profile page for a user ''' - user = request.user - - form = forms.EditUserForm(instance=request.user) + ''' edit profile page for a user ''' data = { 'title': 'Edit profile', - 'form': form, - 'user': user, + 'form': forms.EditUserForm(instance=request.user), + 'user': request.user, } return TemplateResponse(request, 'edit_user.html', data)