diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 03c714a6b..446455fa3 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -4,11 +4,12 @@ import sys from .base_activity import ActivityEncoder, Image, PublicKey, Signature from .note import Note, GeneratedNote, Article, Comment, Review, Quotation +from .note import Tombstone from .interaction import Boost, Like from .ordered_collection import OrderedCollection, OrderedCollectionPage from .person import Person from .book import Edition, Work, Author -from .verbs import Create, Undo, Update +from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject from .verbs import Add, Remove diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 63ac8a6e0..54730fb67 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -4,6 +4,14 @@ from typing import Dict, List from .base_activity import ActivityObject, Image +@dataclass(init=False) +class Tombstone(ActivityObject): + url: str + published: str + deleted: str + type: str = 'Tombstone' + + @dataclass(init=False) class Note(ActivityObject): ''' Note activity ''' diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 1ae106b0f..bd6d882d3 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -21,6 +21,15 @@ class Create(Verb): type: str = 'Create' +@dataclass(init=False) +class Delete(Verb): + ''' Create activity ''' + to: List + cc: List + signature: Signature + type: str = 'Delete' + + @dataclass(init=False) class Update(Verb): ''' Update activity ''' diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index d348e43a7..7fbb99f9b 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -57,6 +57,7 @@ def shared_inbox(request): 'Accept': handle_follow_accept, 'Reject': handle_follow_reject, 'Create': handle_create, + 'Delete': handle_delete_status, 'Like': handle_favorite, 'Announce': handle_boost, 'Add': { @@ -134,7 +135,9 @@ def handle_follow(activity): 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']) + relationship = models.UserFollowRequest.objects.get( + remote_id=activity['id'] + ) # send the accept normally for a duplicate request if not to_follow.manually_approves_followers: @@ -143,7 +146,7 @@ def handle_follow(activity): 'FOLLOW', related_user=actor ) - outgoing.handle_accept(actor, to_follow, relationship) + outgoing.handle_accept(relationship) else: # Accept will be triggered manually status_builder.create_notification( @@ -194,7 +197,7 @@ def handle_follow_reject(activity): user_object=rejecter ) request.delete() - #raises models.UserFollowRequest.DoesNotExist: + #raises models.UserFollowRequest.DoesNotExist @app.task @@ -235,6 +238,20 @@ def handle_create(activity): ) +@app.task +def handle_delete_status(activity): + ''' remove a status ''' + status_id = activity['object']['id'] + try: + status = models.Status.objects.select_subclasses().get( + remote_id=status_id + ) + except models.Status.DoesNotExist: + return + status_builder.delete_status(status) + + + @app.task def handle_favorite(activity): ''' approval of your good good post ''' diff --git a/bookwyrm/migrations/0053_auto_20201006_2020.py b/bookwyrm/migrations/0053_auto_20201006_2020.py new file mode 100644 index 000000000..515fc4463 --- /dev/null +++ b/bookwyrm/migrations/0053_auto_20201006_2020.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.7 on 2020-10-06 20:20 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0052_auto_20201005_2145'), + ] + + operations = [ + migrations.AddField( + model_name='status', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='status', + name='deleted_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/bookwyrm/migrations/0054_auto_20201016_2359.py b/bookwyrm/migrations/0054_auto_20201016_2359.py new file mode 100644 index 000000000..c8ab34801 --- /dev/null +++ b/bookwyrm/migrations/0054_auto_20201016_2359.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-10-16 23:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0053_auto_20201006_2020'), + ] + + operations = [ + migrations.AlterField( + model_name='status', + name='deleted_date', + field=models.DateTimeField(), + ), + ] diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 6c3369f21..0a70eb77c 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -22,6 +22,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): sensitive = models.BooleanField(default=False) # the created date can't be this, because of receiving federated posts published_date = models.DateTimeField(default=timezone.now) + deleted = models.BooleanField(default=False) + deleted_date = models.DateTimeField() favorites = models.ManyToManyField( 'User', symmetrical=False, @@ -104,6 +106,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): **kwargs ) + def to_activity(self, **kwargs): + ''' return tombstone if the status is deleted ''' + if self.deleted: + return activitypub.Tombstone( + id=self.remote_id, + url=self.remote_id, + deleted=http_date(self.deleted_date.timestamp()), + published=http_date(self.deleted_date.timestamp()), + ).serialize() + return ActivitypubMixin.to_activity(self, **kwargs) + + class GeneratedStatus(Status): ''' these are app-generated messages about user activity ''' @property @@ -112,7 +126,7 @@ class GeneratedStatus(Status): message = self.content books = ', '.join( '"%s"' % (self.book.local_id, self.book.title) \ - for book in self.mention_books + for book in self.mention_books.all() ) return '%s %s' % (message, books) diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py index 2c4c6def9..f496a2110 100644 --- a/bookwyrm/outgoing.py +++ b/bookwyrm/outgoing.py @@ -13,6 +13,7 @@ from bookwyrm.status import create_review, create_status from bookwyrm.status import create_quotation, create_comment from bookwyrm.status import create_tag, create_notification, create_rating from bookwyrm.status import create_generated_note +from bookwyrm.status import delete_status from bookwyrm.remote_user import get_or_create_remote_user @@ -80,8 +81,10 @@ def handle_unfollow(user, to_unfollow): to_unfollow.followers.remove(user) -def handle_accept(user, to_follow, follow_request): +def handle_accept(follow_request): ''' send an acceptance message to a follow request ''' + user = follow_request.user_subject + to_follow = follow_request.user_object with transaction.atomic(): relationship = models.UserFollows.from_request(follow_request) follow_request.delete() @@ -91,10 +94,12 @@ def handle_accept(user, to_follow, follow_request): broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) -def handle_reject(user, to_follow, relationship): +def handle_reject(follow_request): ''' a local user who managed follows rejects a follow request ''' - activity = relationship.to_reject_activity(user) - relationship.delete() + user = follow_request.user_subject + to_follow = follow_request.user_object + activity = follow_request.to_reject_activity() + follow_request.delete() broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) @@ -107,11 +112,16 @@ def handle_shelve(user, book, shelf): broadcast(user, shelve.to_add_activity(user)) # tell the world about this cool thing that happened - message = { - 'to-read': 'wants to read', - 'reading': 'started reading', - 'read': 'finished reading' - }[shelf.identifier] + try: + message = { + 'to-read': 'wants to read', + 'reading': 'started reading', + 'read': 'finished reading' + }[shelf.identifier] + except KeyError: + # it's a non-standard shelf, don't worry about it + return + status = create_generated_note(user, message, mention_books=[book]) status.save() @@ -193,6 +203,12 @@ def handle_import_books(user, items): return None +def handle_delete_status(user, status): + ''' delete a status and broadcast deletion to other servers ''' + delete_status(status) + broadcast(user, status.to_activity()) + + def handle_rate(user, book, rating): ''' a review that's just a rating ''' builder = create_rating diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 0c13638e2..256198399 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -1,4 +1,5 @@ ''' Handle user activity ''' +from datetime import datetime from django.db import IntegrityError from bookwyrm import models @@ -6,6 +7,12 @@ from bookwyrm.books_manager import get_or_create_book from bookwyrm.sanitize_html import InputHtmlParser +def delete_status(status): + ''' replace the status with a tombstone ''' + status.deleted = True + status.deleted_date = datetime.now() + status.save() + def create_rating(user, book, rating): ''' a review that's just a rating ''' if not rating or rating < 1 or rating > 5: diff --git a/bookwyrm/templates/snippets/status.html b/bookwyrm/templates/snippets/status.html index f1fab7427..5c570e0d6 100644 --- a/bookwyrm/templates/snippets/status.html +++ b/bookwyrm/templates/snippets/status.html @@ -1,6 +1,7 @@ {% load humanize %} {% load fr_display %} +{% if not status.deleted %}
{% include 'snippets/status_header.html' with status=status %} @@ -25,7 +26,29 @@ Public post + {% if status.user == request.user %} +
+ {% csrf_token %} + + +
+ {% endif %} {{ status.published_date | naturaltime }}
+{% else %} +
+
+

+ {% include 'snippets/avatar.html' with user=status.user %} + {% include 'snippets/username.html' with user=status.user %} + deleted this status +

+
+
+{% endif %} diff --git a/bookwyrm/templatetags/fr_display.py b/bookwyrm/templatetags/fr_display.py index 818eae2af..cb4ee4198 100644 --- a/bookwyrm/templatetags/fr_display.py +++ b/bookwyrm/templatetags/fr_display.py @@ -42,7 +42,8 @@ def get_replies(status): ''' get all direct replies to a status ''' #TODO: this limit could cause problems return models.Status.objects.filter( - reply_parent=status + reply_parent=status, + deleted=False, ).select_subclasses().all()[:10] diff --git a/bookwyrm/tests/incoming/__init__.py b/bookwyrm/tests/incoming/__init__.py new file mode 100644 index 000000000..b6e690fd5 --- /dev/null +++ b/bookwyrm/tests/incoming/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/test_incoming_favorite.py b/bookwyrm/tests/incoming/test_favorite.py similarity index 95% rename from bookwyrm/tests/test_incoming_favorite.py rename to bookwyrm/tests/incoming/test_favorite.py index 035021452..eeba9000a 100644 --- a/bookwyrm/tests/test_incoming_favorite.py +++ b/bookwyrm/tests/incoming/test_favorite.py @@ -6,7 +6,6 @@ from bookwyrm import models, incoming class Favorite(TestCase): - ''' not too much going on in the books model but here we are ''' def setUp(self): self.remote_user = models.User.objects.create_user( 'rat', 'rat@rat.com', 'ratword', @@ -25,7 +24,7 @@ class Favorite(TestCase): ) datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_user.json' + '../data/ap_user.json' ) self.user_data = json.loads(datafile.read_bytes()) diff --git a/bookwyrm/tests/test_incoming_follow.py b/bookwyrm/tests/incoming/test_follow.py similarity index 96% rename from bookwyrm/tests/test_incoming_follow.py rename to bookwyrm/tests/incoming/test_follow.py index b5fbec008..51ab3c43a 100644 --- a/bookwyrm/tests/test_incoming_follow.py +++ b/bookwyrm/tests/incoming/test_follow.py @@ -1,11 +1,9 @@ -import json from django.test import TestCase from bookwyrm import models, incoming -class Follow(TestCase): - ''' not too much going on in the books model but here we are ''' +class IncomingFollow(TestCase): def setUp(self): self.remote_user = models.User.objects.create_user( 'rat', 'rat@rat.com', 'ratword', diff --git a/bookwyrm/tests/test_incoming_follow_accept.py b/bookwyrm/tests/incoming/test_follow_accept.py similarity index 95% rename from bookwyrm/tests/test_incoming_follow_accept.py rename to bookwyrm/tests/incoming/test_follow_accept.py index c30dc2486..ba88bb40d 100644 --- a/bookwyrm/tests/test_incoming_follow_accept.py +++ b/bookwyrm/tests/incoming/test_follow_accept.py @@ -1,11 +1,9 @@ -import json from django.test import TestCase from bookwyrm import models, incoming class IncomingFollowAccept(TestCase): - ''' not too much going on in the books model but here we are ''' def setUp(self): self.remote_user = models.User.objects.create_user( 'rat', 'rat@rat.com', 'ratword', diff --git a/bookwyrm/tests/outgoing/__init__.py b/bookwyrm/tests/outgoing/__init__.py new file mode 100644 index 000000000..b6e690fd5 --- /dev/null +++ b/bookwyrm/tests/outgoing/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/outgoing/test_follow.py b/bookwyrm/tests/outgoing/test_follow.py new file mode 100644 index 000000000..82a476f62 --- /dev/null +++ b/bookwyrm/tests/outgoing/test_follow.py @@ -0,0 +1,72 @@ +from django.test import TestCase + +from bookwyrm import models, outgoing + + +class Following(TestCase): + def setUp(self): + 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', + ) + self.local_user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', + local=True, + remote_id='http://local.com/users/mouse', + ) + + + def test_handle_follow(self): + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + outgoing.handle_follow(self.local_user, self.remote_user) + rel = models.UserFollowRequest.objects.get() + + self.assertEqual(rel.user_subject, self.local_user) + self.assertEqual(rel.user_object, self.remote_user) + self.assertEqual(rel.status, 'follow_request') + + + def test_handle_unfollow(self): + self.remote_user.followers.add(self.local_user) + self.assertEqual(self.remote_user.followers.count(), 1) + outgoing.handle_unfollow(self.local_user, self.remote_user) + + self.assertEqual(self.remote_user.followers.count(), 0) + + + def test_handle_accept(self): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + rel_id = rel.id + + outgoing.handle_accept(rel) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 + ) + # follow relationship should exist + self.assertEqual(self.remote_user.followers.first(), self.local_user) + + + def test_handle_reject(self): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + rel_id = rel.id + + outgoing.handle_reject(rel) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 + ) + # follow relationship should not exist + self.assertEqual( + models.UserFollows.objects.filter(id=rel_id).count(), 0 + ) diff --git a/bookwyrm/tests/outgoing/test_shelving.py b/bookwyrm/tests/outgoing/test_shelving.py new file mode 100644 index 000000000..acf816e11 --- /dev/null +++ b/bookwyrm/tests/outgoing/test_shelving.py @@ -0,0 +1,97 @@ +from django.test import TestCase + +from bookwyrm import models, outgoing + + +class Shelving(TestCase): + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', + local=True, + remote_id='http://local.com/users/mouse', + ) + self.book = models.Edition.objects.create( + title='Example Edition', + remote_id='https://example.com/book/1', + ) + self.shelf = models.Shelf.objects.create( + name='Test Shelf', + identifier='test-shelf', + user=self.user + ) + + + def test_handle_shelve(self): + outgoing.handle_shelve(self.user, self.book, self.shelf) + # make sure the book is on the shelf + self.assertEqual(self.shelf.books.get(), self.book) + + + def test_handle_shelve_to_read(self): + shelf = models.Shelf.objects.get(identifier='to-read') + + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + # it should have posted a status about this + status = models.GeneratedStatus.objects.get() + self.assertEqual(status.content, 'wants to read') + self.assertEqual(status.user, self.user) + self.assertEqual(status.mention_books.count(), 1) + self.assertEqual(status.mention_books.first(), self.book) + + # and it should not create a read-through + self.assertEqual(models.ReadThrough.objects.count(), 0) + + + def test_handle_shelve_reading(self): + shelf = models.Shelf.objects.get(identifier='reading') + + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + # it should have posted a status about this + status = models.GeneratedStatus.objects.order_by('-published_date').first() + self.assertEqual(status.content, 'started reading') + self.assertEqual(status.user, self.user) + self.assertEqual(status.mention_books.count(), 1) + self.assertEqual(status.mention_books.first(), self.book) + + # and it should create a read-through + readthrough = models.ReadThrough.objects.get() + self.assertEqual(readthrough.user, self.user) + self.assertEqual(readthrough.book.id, self.book.id) + self.assertIsNotNone(readthrough.start_date) + self.assertIsNone(readthrough.finish_date) + + + def test_handle_shelve_read(self): + shelf = models.Shelf.objects.get(identifier='read') + + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + # it should have posted a status about this + status = models.GeneratedStatus.objects.order_by('-published_date').first() + self.assertEqual(status.content, 'finished reading') + self.assertEqual(status.user, self.user) + self.assertEqual(status.mention_books.count(), 1) + self.assertEqual(status.mention_books.first(), self.book) + + # and it should update the existing read-through + readthrough = models.ReadThrough.objects.get() + self.assertEqual(readthrough.user, self.user) + self.assertEqual(readthrough.book.id, self.book.id) + self.assertIsNotNone(readthrough.start_date) + self.assertIsNotNone(readthrough.finish_date) + + + def test_handle_unshelve(self): + self.shelf.books.add(self.book) + self.shelf.save() + self.assertEqual(self.shelf.books.count(), 1) + outgoing.handle_unshelve(self.user, self.book, self.shelf) + self.assertEqual(self.shelf.books.count(), 0) diff --git a/bookwyrm/tests/status/test_quotation.py b/bookwyrm/tests/status/test_quotation.py index 57755560a..4892e21d3 100644 --- a/bookwyrm/tests/status/test_quotation.py +++ b/bookwyrm/tests/status/test_quotation.py @@ -1,8 +1,6 @@ from django.test import TestCase -import json -import pathlib -from bookwyrm import activitypub, models +from bookwyrm import models from bookwyrm import status as status_builder diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index f1b33877a..331efee5f 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -11,7 +11,7 @@ localname_regex = r'(?P[\w\-_]+)' user_path = r'^user/%s' % username_regex local_user_path = r'^user/%s' % localname_regex -status_types = ['status', 'review', 'comment', 'quotation', 'boost'] +status_types = ['status', 'review', 'comment', 'quotation', 'boost', 'generatedstatus'] status_path = r'%s/(%s)/(?P\d+)' % \ (local_user_path, '|'.join(status_types)) @@ -107,6 +107,8 @@ urlpatterns = [ re_path(r'^unfavorite/(?P\d+)/?$', actions.unfavorite), re_path(r'^boost/(?P\d+)/?$', actions.boost), + re_path(r'^delete-status/?$', actions.delete_status), + re_path(r'^shelve/?$', actions.shelve), re_path(r'^follow/?$', actions.follow), diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 992a270dd..54ed353a0 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -418,6 +418,27 @@ def boost(request, status_id): outgoing.handle_boost(request.user, status) return redirect(request.headers.get('Referer', '/')) + +@login_required +def delete_status(request): + ''' delete and tombstone a status ''' + status_id = request.POST.get('status') + if not status_id: + return HttpResponseBadRequest() + try: + status = models.Status.objects.get(id=status_id) + except models.Status.DoesNotExist: + return HttpResponseBadRequest() + + # don't let people delete other people's statuses + if status.user != request.user: + return HttpResponseBadRequest() + + # perform deletion + outgoing.handle_delete_status(request.user, status) + return redirect(request.headers.get('Referer', '/')) + + @login_required def follow(request): ''' follow another user, here or abroad ''' @@ -473,7 +494,7 @@ def accept_follow_request(request): # Request already dealt with. pass else: - outgoing.handle_accept(requester, request.user, follow_request) + outgoing.handle_accept(follow_request) return redirect('/user/%s' % request.user.localname) @@ -495,7 +516,7 @@ def delete_follow_request(request): except models.UserFollowRequest.DoesNotExist: return HttpResponseBadRequest() - outgoing.handle_reject(requester, request.user, follow_request) + outgoing.handle_reject(follow_request) return redirect('/user/%s' % request.user.localname) diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 9217c4b3e..55957ff29 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -118,7 +118,7 @@ def get_activity_feed(user, filter_level, model=models.Status): activities = model if hasattr(model, 'objects'): - activities = model.objects + activities = model.objects.filter(deleted=False) activities = activities.order_by( '-created_date'