diff --git a/fedireads/activitypub/__init__.py b/fedireads/activitypub/__init__.py new file mode 100644 index 00000000..ae927069 --- /dev/null +++ b/fedireads/activitypub/__init__.py @@ -0,0 +1,7 @@ +''' bring activitypub functions into the namespace ''' +from .actor import get_actor +from .collection import get_outbox, get_outbox_page, get_add, get_remove, \ + get_following, get_followers +from .create import get_create +from .follow import get_follow_request, get_accept +from .status import get_review, get_review_article, get_status, get_replies diff --git a/fedireads/activitypub/actor.py b/fedireads/activitypub/actor.py new file mode 100644 index 00000000..63d49401 --- /dev/null +++ b/fedireads/activitypub/actor.py @@ -0,0 +1,28 @@ +''' actor serializer ''' + +def get_actor(user): + ''' activitypub actor from db User ''' + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + + 'id': user.actor, + 'type': 'Person', + 'preferredUsername': user.localname, + 'name': user.name, + 'inbox': user.inbox, + 'followers': '%s/followers' % user.actor, + 'following': '%s/following' % user.actor, + 'summary': user.summary, + 'publicKey': { + 'id': '%s/#main-key' % user.actor, + 'owner': user.actor, + 'publicKeyPem': user.public_key, + }, + 'endpoints': { + 'sharedInbox': user.shared_inbox, + } + } + diff --git a/fedireads/activitypub/collection.py b/fedireads/activitypub/collection.py new file mode 100644 index 00000000..34ce2318 --- /dev/null +++ b/fedireads/activitypub/collection.py @@ -0,0 +1,128 @@ +''' activitypub json for collections ''' +from uuid import uuid4 +from urllib.parse import urlencode + +from .status import get_status + +def get_outbox(user, size): + ''' helper function for creating an outbox ''' + return get_ordered_collection(user.outbox, size) + + +def get_outbox_page(user, page_id, statuses, max_id, min_id): + ''' helper for formatting outbox pages ''' + # not generalizing this more because the format varies for some reason + page = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': page_id, + 'type': 'OrderedCollectionPage', + 'partOf': user.outbox, + 'orderedItems': [], + } + + for status in statuses: + page['orderedItems'].append(get_status(status)) + + if max_id: + page['next'] = user.outbox + '?' + \ + urlencode({'min_id': max_id, 'page': 'true'}) + if min_id: + page['prev'] = user.outbox + '?' + \ + urlencode({'max_id': min_id, 'page': 'true'}) + + return page + + +def get_followers(user, page, follow_queryset): + ''' list of people who follow a user ''' + id_slug = '%s/followers' % user.actor + return get_follow_info(id_slug, page, follow_queryset) + + +def get_following(user, page, follow_queryset): + ''' list of people who follow a user ''' + id_slug = '%s/following' % user.actor + return get_follow_info(id_slug, page, follow_queryset) + + +def get_follow_info(id_slug, page, follow_queryset): + ''' a list of followers or following ''' + if page: + return get_follow_page(follow_queryset, id_slug, page) + count = follow_queryset.count() + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id_slug, + 'type': 'OrderedCollection', + 'totalItems': count, + 'first': '%s?page=1' % id_slug, + } + + +# TODO: generalize these pagination functions +def get_follow_page(user_list, id_slug, page): + ''' format a list of followers/following ''' + page = int(page) + page_length = 10 + start = (page - 1) * page_length + end = start + page_length + follower_page = user_list.all()[start:end] + data = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': '%s?page=%d' % (id_slug, page), + 'type': 'OrderedCollectionPage', + 'totalItems': user_list.count(), + 'partOf': id_slug, + 'orderedItems': [u.actor for u in follower_page], + } + if end <= user_list.count(): + # there are still more pages + data['next'] = '%s?page=%d' % (id_slug, page + 1) + if start > 0: + data['prev'] = '%s?page=%d' % (id_slug, page - 1) + return data + + +def get_ordered_collection(id_slug, size): + ''' create an ordered collection ''' + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id_slug, + 'type': 'OrderedCollection', + 'totalItems': size, + 'first': '%s?page=true' % id_slug, + 'last': '%s?min_id=0&page=true' % id_slug + } + + +def get_add(*args): + ''' activitypub Add activity ''' + return get_add_remove(*args, action='Add') + + +def get_remove(*args): + ''' activitypub Add activity ''' + return get_add_remove(*args, action='Remove') + + +def get_add_remove(user, book, shelf, action='Add'): + ''' format an Add or Remove json blob ''' + uuid = uuid4() + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': str(uuid), + 'type': action, + 'actor': user.actor, + 'object': { + 'type': 'Document', + 'name': book.data['title'], + 'url': book.openlibrary_key + }, + 'target': { + 'type': 'Collection', + 'name': shelf.name, + 'id': shelf.absolute_id, + } + } + + diff --git a/fedireads/activitypub/create.py b/fedireads/activitypub/create.py new file mode 100644 index 00000000..a3d4d7a1 --- /dev/null +++ b/fedireads/activitypub/create.py @@ -0,0 +1,33 @@ +''' format Create activities and sign them ''' +from base64 import b64encode +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from Crypto.Hash import SHA256 + +def get_create(user, status_json): + ''' create activitypub json for a Create activity ''' + signer = pkcs1_15.new(RSA.import_key(user.private_key)) + content = status_json['content'] + signed_message = signer.sign(SHA256.new(content.encode('utf8'))) + return { + '@context': 'https://www.w3.org/ns/activitystreams', + + 'id': '%s/activity' % status_json['id'], + 'type': 'Create', + 'actor': user.actor, + 'published': status_json['published'], + + 'to': ['%s/followers' % user.actor], + 'cc': ['https://www.w3.org/ns/activitystreams#Public'], + + 'object': status_json, + 'signature': { + 'type': 'RsaSignature2017', + 'creator': '%s#main-key' % user.absolute_id, + 'created': status_json['published'], + 'signatureValue': b64encode(signed_message).decode('utf8'), + } + } + + + diff --git a/fedireads/activitypub/follow.py b/fedireads/activitypub/follow.py new file mode 100644 index 00000000..599c2ff3 --- /dev/null +++ b/fedireads/activitypub/follow.py @@ -0,0 +1,29 @@ +''' makin' freinds inthe ap json format ''' +from uuid import uuid4 + +from fedireads.settings import DOMAIN + + +def get_follow_request(user, to_follow): + ''' a local user wants to follow someone ''' + uuid = uuid4() + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': 'https://%s/%s' % (DOMAIN, str(uuid)), + 'summary': '', + 'type': 'Follow', + 'actor': user.actor, + 'object': to_follow.actor, + } + + +def get_accept(user, request_activity): + ''' accept a follow request ''' + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': '%s#accepts/follows/' % user.absolute_id, + 'type': 'Accept', + 'actor': user.actor, + 'object': request_activity, + } + diff --git a/fedireads/activitypub/status.py b/fedireads/activitypub/status.py new file mode 100644 index 00000000..81508daf --- /dev/null +++ b/fedireads/activitypub/status.py @@ -0,0 +1,73 @@ +''' status serializers ''' +def get_review(review): + ''' fedireads json for book reviews ''' + status = get_status(review) + status['inReplyToBook'] = review.book.absolute_id + status['fedireadsType'] = review.status_type, + status['name'] = review.name + status['rating'] = review.rating + return status + + +def get_review_article(review): + ''' a book review formatted for a non-fedireads isntance (mastodon) ''' + status = get_status(review) + name = 'Review of "%s" (%d stars): %s' % ( + review.book.data['title'], + review.rating, + review.name + ) + status['name'] = name + return status + + +def get_status(status): + ''' create activitypub json for a status ''' + user = status.user + uri = status.absolute_id + reply_parent_id = status.reply_parent.absolute_id \ + if status.reply_parent else None + status_json = { + 'id': uri, + 'url': uri, + 'inReplyTo': reply_parent_id, + 'published': status.created_date.isoformat(), + 'attributedTo': user.actor, + # TODO: assuming all posts are public -- should check privacy db field + 'to': ['https://www.w3.org/ns/activitystreams#Public'], + 'cc': ['%s/followers' % user.absolute_id], + 'sensitive': status.sensitive, + 'content': status.content, + 'type': status.activity_type, + 'attachment': [], # TODO: the book cover + 'replies': { + 'id': '%s/replies' % uri, + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'next': '%s/replies?only_other_accounts=true&page=true' % uri, + 'partOf': '%s/replies' % uri, + 'items': [], # TODO: populate with replies + } + } + } + + return status_json + + +def get_replies(status, replies): + ''' collection of replies ''' + id_slug = status.absolute_id + '/replies' + # TODO only partially implemented + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id_slug, + 'type': 'Collection', + 'first': { + 'id': '%s?page=true' % id_slug, + 'type': 'CollectionPage', + 'next': '%s?only_other_accounts=true&page=true' % id_slug, + 'partOf': id_slug, + 'items': [get_status(r) for r in replies] + } + } diff --git a/fedireads/broadcast.py b/fedireads/broadcast.py index 49fbeb7f..bc2a0da8 100644 --- a/fedireads/broadcast.py +++ b/fedireads/broadcast.py @@ -9,7 +9,7 @@ import requests from urllib.parse import urlparse -def get_recipients(user, post_privacy, direct_recipients=None): +def get_recipients(user, post_privacy, direct_recipients=None, limit=False): ''' deduplicated list of recipient inboxes ''' recipients = direct_recipients or [] if post_privacy == 'direct': @@ -17,7 +17,12 @@ def get_recipients(user, post_privacy, direct_recipients=None): return [u.inbox for u in recipients] # load all the followers of the user who is sending the message - followers = user.followers.all() + if not limit: + followers = user.followers.all() + else: + fedireads_user = limit == 'fedireads' + followers = user.followers.filter(fedireads_user=fedireads_user).all() + if post_privacy == 'public': # post to public shared inboxes shared_inboxes = set( diff --git a/fedireads/forms.py b/fedireads/forms.py index 5a5948be..75bc56ab 100644 --- a/fedireads/forms.py +++ b/fedireads/forms.py @@ -40,8 +40,17 @@ class ReviewForm(ModelForm): } +class CommentForm(ModelForm): + class Meta: + model = models.Status + fields = ['content'] + help_texts = {f: None for f in fields} + labels = {'content': 'Comment'} + + class EditUserForm(ModelForm): class Meta: model = models.User fields = ['avatar', 'name', 'summary'] help_texts = {f: None for f in fields} + diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 786163f2..e5b6653b 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -9,9 +9,10 @@ from django.views.decorators.csrf import csrf_exempt import json import requests +from fedireads import activitypub from fedireads import models from fedireads import outgoing -from fedireads.activity import create_review +from fedireads.status import create_review, create_status from fedireads.remote_user import get_or_create_remote_user @@ -33,10 +34,7 @@ def shared_inbox(request): return HttpResponse(status=401) response = HttpResponseNotFound() - if activity['type'] == 'Add': - response = handle_incoming_shelve(activity) - - elif activity['type'] == 'Follow': + if activity['type'] == 'Follow': response = handle_incoming_follow(activity) elif activity['type'] == 'Create': @@ -45,7 +43,7 @@ def shared_inbox(request): elif activity['type'] == 'Accept': response = handle_incoming_follow_accept(activity) - # TODO: Undo, Remove, etc + # TODO: Add, Undo, Remove, etc return response @@ -114,29 +112,7 @@ def get_actor(request, username): return HttpResponseBadRequest() user = models.User.objects.get(localname=username) - return JsonResponse({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1' - ], - - 'id': user.actor, - 'type': 'Person', - 'preferredUsername': user.localname, - 'name': user.name, - 'inbox': user.inbox, - 'followers': '%s/followers' % user.actor, - 'following': '%s/following' % user.actor, - 'summary': user.summary, - 'publicKey': { - 'id': '%s/#main-key' % user.actor, - 'owner': user.actor, - 'publicKeyPem': user.public_key, - }, - 'endpoints': { - 'sharedInbox': user.shared_inbox, - } - }) + return JsonResponse(activitypub.get_actor(user)) @csrf_exempt @@ -154,7 +130,26 @@ def get_status(request, username, status_id): if user != status.user: return HttpResponseNotFound() - return JsonResponse(status.activity) + return JsonResponse(activitypub.get_status(status)) + + +@csrf_exempt +def get_replies(request, username, status_id): + ''' ordered collection of replies to a status ''' + # TODO: this isn't a full implmentation + if request.method != 'GET': + return HttpResponseBadRequest() + + status = models.Status.objects.get(id=status_id) + if status.user.localname != username: + return HttpResponseNotFound() + + replies = models.Status.objects.filter( + reply_parent=status + ).first() + + replies_activity = activitypub.get_replies(status, [replies]) + return JsonResponse(replies_activity) @csrf_exempt @@ -165,7 +160,8 @@ def get_followers(request, username): user = models.User.objects.get(localname=username) followers = user.followers - return format_follow_info(user, request.GET.get('page'), followers) + page = request.GET.get('page') + return JsonResponse(activitypub.get_followers(user, page, followers)) @csrf_exempt @@ -176,70 +172,8 @@ def get_following(request, username): user = models.User.objects.get(localname=username) following = models.User.objects.filter(followers=user) - return format_follow_info(user, request.GET.get('page'), following) - - -def format_follow_info(user, page, follow_queryset): - ''' create the activitypub json for followers/following ''' - id_slug = '%s/following' % user.actor - if page: - return JsonResponse(get_follow_page(follow_queryset, id_slug, page)) - count = follow_queryset.count() - return JsonResponse({ - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': id_slug, - 'type': 'OrderedCollection', - 'totalItems': count, - 'first': '%s?page=1' % id_slug, - }) - - -def get_follow_page(user_list, id_slug, page): - ''' format a list of followers/following ''' - page = int(page) - page_length = 10 - start = (page - 1) * page_length - end = start + page_length - follower_page = user_list.all()[start:end] - data = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': '%s?page=%d' % (id_slug, page), - 'type': 'OrderedCollectionPage', - 'totalItems': user_list.count(), - 'partOf': id_slug, - 'orderedItems': [u.actor for u in follower_page], - } - if end <= user_list.count(): - # there are still more pages - data['next'] = '%s?page=%d' % (id_slug, page + 1) - if start > 0: - data['prev'] = '%s?page=%d' % (id_slug, page - 1) - return data - - -def handle_incoming_shelve(activity): - ''' receiving an Add activity (to shelve a book) ''' - # TODO what happens here? If it's a remote over, then I think - # I should save both the activity and the ShelfBook entry. But - # I'll do that later. - uuid = activity['id'] - models.ShelveActivity.objects.get(uuid=uuid) - ''' - book_id = activity['object']['url'] - book = openlibrary.get_or_create_book(book_id) - user_ap_id = activity['actor'].replace('https//:', '') - user = models.User.objects.get(actor=user_ap_id) - if not user or not user.local: - return HttpResponseBadRequest() - - shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id']) - models.ShelfBook( - shelf=shelf, - book=book, - added_by=user, - ).save() - ''' - return HttpResponse() + page = request.GET.get('page') + return JsonResponse(activitypub.get_following(user, page, following)) def handle_incoming_follow(activity): @@ -248,12 +182,6 @@ def handle_incoming_follow(activity): to_follow = models.User.objects.get(actor=activity['object']) # figure out who they are user = get_or_create_remote_user(activity['actor']) - models.FollowActivity( - uuid=activity['id'], - user=user, - followed=to_follow, - content=activity, - ) # TODO: allow users to manually approve requests outgoing.handle_outgoing_accept(user, to_follow, activity) return HttpResponse() @@ -277,37 +205,30 @@ def handle_incoming_create(activity): if not 'object' in activity: return HttpResponseBadRequest() + # TODO: should only create notes if they are relevent to a book, + # so, not every single thing someone posts on mastodon response = HttpResponse() + content = activity['object'].get('content') if activity['object'].get('fedireadsType') == 'Review' and \ - 'inReplyTo' in activity['object']: - book = activity['object']['inReplyTo'] + 'inReplyToBook' in activity['object']: + book = activity['object']['inReplyToBook'] book = book.split('/')[-1] name = activity['object'].get('name') - content = activity['object'].get('content') rating = activity['object'].get('rating') if user.local: review_id = activity['object']['id'].split('/')[-1] - review = models.Review.objects.get(id=review_id) + models.Review.objects.get(id=review_id) else: try: - review = create_review(user, book, name, content, rating) + create_review(user, book, name, content, rating) except ValueError: return HttpResponseBadRequest() - models.ReviewActivity.objects.create( - uuid=activity['id'], - user=user, - content=activity['object'], - activity_type=activity['object']['type'], - book=review.book, - ) + elif not user.local: + try: + create_status(user, content) + except ValueError: + return HttpResponseBadRequest() - else: - models.Activity.objects.create( - uuid=activity['id'], - user=user, - content=activity, - activity_type=activity['object']['type'] - ) return response @@ -321,12 +242,5 @@ def handle_incoming_accept(activity): # save this relationship in the db followed.followers.add(user) - # save the activity record - models.FollowActivity( - uuid=activity['id'], - user=user, - followed=followed, - content=activity, - ).save() return HttpResponse() diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index 4b9ee2e1..85d90959 100644 --- a/fedireads/migrations/0001_initial.py +++ b/fedireads/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-15 22:50 +# Generated by Django 3.0.3 on 2020-02-17 02:39 from django.conf import settings import django.contrib.auth.models @@ -56,20 +56,6 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name='Activity', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.CharField(max_length=255, unique=True)), - ('content', fedireads.utils.fields.JSONField(max_length=5000)), - ('activity_type', models.CharField(max_length=255)), - ('fedireads_type', models.CharField(blank=True, max_length=255, null=True)), - ('local', models.BooleanField(default=True)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='Author', fields=[ @@ -118,11 +104,14 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('status_type', models.CharField(default='Note', max_length=255)), - ('activity', fedireads.utils.fields.JSONField(max_length=5000, null=True)), + ('activity_type', models.CharField(default='Note', max_length=255)), ('local', models.BooleanField(default=True)), + ('privacy', models.CharField(default='public', max_length=255)), + ('sensitive', models.BooleanField(default=False)), ('content', models.TextField(blank=True, null=True)), ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), + ('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')), + ('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)), ('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ], @@ -175,27 +164,10 @@ class Migration(migrations.Migration): name='user_permissions', field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), ), - migrations.CreateModel( - name='ShelveActivity', - fields=[ - ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), - ('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Shelf')), - ], - bases=('fedireads.activity',), - ), migrations.AlterUniqueTogether( name='shelf', unique_together={('user', 'identifier')}, ), - migrations.CreateModel( - name='ReviewActivity', - fields=[ - ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), - ], - bases=('fedireads.activity',), - ), migrations.CreateModel( name='Review', fields=[ @@ -206,12 +178,4 @@ class Migration(migrations.Migration): ], bases=('fedireads.status',), ), - migrations.CreateModel( - name='FollowActivity', - fields=[ - ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')), - ('followed', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='followed', to=settings.AUTH_USER_MODEL)), - ], - bases=('fedireads.activity',), - ), ] diff --git a/fedireads/migrations/0002_auto_20200219_0118.py b/fedireads/migrations/0002_auto_20200219_0118.py new file mode 100644 index 00000000..ac28d9d9 --- /dev/null +++ b/fedireads/migrations/0002_auto_20200219_0118.py @@ -0,0 +1,80 @@ +# Generated by Django 3.0.3 on 2020-02-19 01:18 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('fedireads', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='author', + old_name='added_date', + new_name='created_date', + ), + migrations.RenameField( + model_name='book', + old_name='added_date', + new_name='created_date', + ), + migrations.RemoveField( + model_name='author', + name='updated_date', + ), + migrations.RemoveField( + model_name='book', + name='updated_date', + ), + migrations.RemoveField( + model_name='shelf', + name='updated_date', + ), + migrations.RemoveField( + model_name='user', + name='created_date', + ), + migrations.RemoveField( + model_name='user', + name='updated_date', + ), + migrations.AddField( + model_name='author', + name='content', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='content', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='federatedserver', + name='content', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='federatedserver', + name='created_date', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='shelf', + name='content', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='shelfbook', + name='content', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='fedireads_user', + field=models.BooleanField(default=True), + ), + ] diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index a3e28675..b54cee60 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -1,6 +1,5 @@ ''' bring all the models into the app namespace ''' from .book import Shelf, ShelfBook, Book, Author from .user import User, FederatedServer -from .activity import Activity, ShelveActivity, FollowActivity, \ - ReviewActivity, Status, Review +from .activity import Status, Review diff --git a/fedireads/models/activity.py b/fedireads/models/activity.py index 67d35cf0..8cbceea6 100644 --- a/fedireads/models/activity.py +++ b/fedireads/models/activity.py @@ -3,81 +3,31 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from model_utils.managers import InheritanceManager -from fedireads.utils.fields import JSONField - -# TODO: I don't know that these Activity models should exist, at least in this way -# but I'm not sure what the right approach is for now. - -class Activity(models.Model): - ''' basic fields for storing activities ''' - uuid = models.CharField(max_length=255, unique=True) - user = models.ForeignKey('User', on_delete=models.PROTECT) - content = JSONField(max_length=5000) - # the activitypub activity type (Create, Add, Follow, ...) - activity_type = models.CharField(max_length=255) - # custom types internal to fedireads (Review, Shelve, ...) - fedireads_type = models.CharField(max_length=255, blank=True, null=True) - local = models.BooleanField(default=True) - created_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) - objects = InheritanceManager() +from fedireads.utils.models import FedireadsModel -class ShelveActivity(Activity): - ''' someone put a book on a shelf ''' - book = models.ForeignKey('Book', on_delete=models.PROTECT) - shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) - - def save(self, *args, **kwargs): - if not self.activity_type: - self.activity_type = 'Add' - self.fedireads_type = 'Shelve' - super().save(*args, **kwargs) - - -class FollowActivity(Activity): - ''' record follow requests sent out ''' - followed = models.ForeignKey( - 'User', - related_name='followed', - on_delete=models.PROTECT - ) - - def save(self, *args, **kwargs): - self.activity_type = 'Follow' - super().save(*args, **kwargs) - - -class ReviewActivity(Activity): - book = models.ForeignKey('Book', on_delete=models.PROTECT) - - def save(self, *args, **kwargs): - self.activity_type = 'Note' - self.fedireads_type = 'Review' - super().save(*args, **kwargs) - - -class Status(models.Model): - ''' reply to a review, etc ''' +class Status(FedireadsModel): + ''' any post, like a reply to a review, etc ''' user = models.ForeignKey('User', on_delete=models.PROTECT) status_type = models.CharField(max_length=255, default='Note') - activity = JSONField(max_length=5000, null=True) + mention_users = models.ManyToManyField('User', related_name='mention_user') + mention_books = models.ManyToManyField('Book', related_name='mention_book') + activity_type = models.CharField(max_length=255, default='Note') local = models.BooleanField(default=True) + privacy = models.CharField(max_length=255, default='public') + sensitive = models.BooleanField(default=False) reply_parent = models.ForeignKey( 'self', null=True, on_delete=models.PROTECT ) - content = models.TextField(blank=True, null=True) - created_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) objects = InheritanceManager() class Review(Status): ''' a book review ''' - book = models.ForeignKey('Book', on_delete=models.PROTECT) name = models.CharField(max_length=255) + book = models.ForeignKey('Book', on_delete=models.PROTECT) rating = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(5)] @@ -85,6 +35,6 @@ class Review(Status): def save(self, *args, **kwargs): self.status_type = 'Review' + self.activity_type = 'Article' super().save(*args, **kwargs) - diff --git a/fedireads/models/book.py b/fedireads/models/book.py index e4b73dbe..c067db07 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -1,9 +1,12 @@ ''' database schema for books and shelves ''' from django.db import models + +from fedireads.settings import DOMAIN from fedireads.utils.fields import JSONField +from fedireads.utils.models import FedireadsModel -class Shelf(models.Model): +class Shelf(FedireadsModel): name = models.CharField(max_length=100) identifier = models.CharField(max_length=100) user = models.ForeignKey('User', on_delete=models.PROTECT) @@ -14,14 +17,19 @@ class Shelf(models.Model): through='ShelfBook', through_fields=('shelf', 'book') ) - created_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) + + @property + def absolute_id(self): + ''' use shelf identifier as absolute id ''' + base_path = self.user.absolute_id + model_name = type(self).__name__.lower() + return '%s/%s/%s' % (base_path, model_name, self.identifier) class Meta: unique_together = ('user', 'identifier') -class ShelfBook(models.Model): +class ShelfBook(FedireadsModel): # many to many join table for books and shelves book = models.ForeignKey('Book', on_delete=models.PROTECT) shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) @@ -31,13 +39,12 @@ class ShelfBook(models.Model): null=True, on_delete=models.PROTECT ) - created_date = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('book', 'shelf') -class Book(models.Model): +class Book(FedireadsModel): ''' a non-canonical copy of a work (not book) from open library ''' openlibrary_key = models.CharField(max_length=255, unique=True) data = JSONField() @@ -56,14 +63,17 @@ class Book(models.Model): null=True, on_delete=models.PROTECT ) - added_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) + + @property + def absolute_id(self): + ''' constructs the absolute reference to any db object ''' + base_path = 'https://%s' % DOMAIN + model_name = type(self).__name__.lower() + return '%s/%s/%s' % (base_path, model_name, self.openlibrary_key) -class Author(models.Model): +class Author(FedireadsModel): ''' copy of an author from OL ''' openlibrary_key = models.CharField(max_length=255) data = JSONField() - added_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) diff --git a/fedireads/models/user.py b/fedireads/models/user.py index d396d38a..038f93b0 100644 --- a/fedireads/models/user.py +++ b/fedireads/models/user.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from fedireads.models import Shelf from fedireads.settings import DOMAIN +from fedireads.utils.models import FedireadsModel class User(AbstractUser): @@ -24,6 +25,7 @@ class User(AbstractUser): outbox = models.CharField(max_length=255, unique=True) summary = models.TextField(blank=True, null=True) local = models.BooleanField(default=True) + fedireads_user = models.BooleanField(default=True) localname = models.CharField( max_length=255, null=True, @@ -33,11 +35,15 @@ class User(AbstractUser): name = models.CharField(max_length=100, blank=True, null=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) followers = models.ManyToManyField('self', symmetrical=False) - created_date = models.DateTimeField(auto_now_add=True) - updated_date = models.DateTimeField(auto_now=True) + + @property + def absolute_id(self): + ''' users are identified by their username, so overriding this prop ''' + model_name = type(self).__name__.lower() + return 'https://%s/%s/%s' % (DOMAIN, model_name, self.localname) -class FederatedServer(models.Model): +class FederatedServer(FedireadsModel): ''' store which server's we federate with ''' server_name = models.CharField(max_length=255, unique=True) # federated, blocked, whatever else @@ -56,10 +62,10 @@ def execute_before_save(sender, instance, *args, **kwargs): # populate fields for local users instance.localname = instance.username instance.username = '%s@%s' % (instance.username, DOMAIN) - instance.actor = 'https://%s/user/%s' % (DOMAIN, instance.localname) - instance.inbox = 'https://%s/user/%s/inbox' % (DOMAIN, instance.localname) + instance.actor = instance.absolute_id + instance.inbox = '%s/inbox' % instance.absolute_id instance.shared_inbox = 'https://%s/inbox' % DOMAIN - instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname) + instance.outbox = '%s/outbox' % instance.absolute_id if not instance.private_key: random_generator = Random.new().read key = RSA.generate(1024, random_generator) diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index df76eb9a..a026b5a1 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -1,20 +1,14 @@ ''' handles all the activity coming out of the server ''' -from base64 import b64encode -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 -from Crypto.Hash import SHA256 -from datetime import datetime from django.http import HttpResponseNotFound, JsonResponse from django.views.decorators.csrf import csrf_exempt import requests from urllib.parse import urlencode -from uuid import uuid4 +from fedireads import activitypub from fedireads import models -from fedireads.activity import create_review +from fedireads.status import create_review, create_status from fedireads.remote_user import get_or_create_remote_user from fedireads.broadcast import get_recipients, broadcast -from fedireads.settings import DOMAIN @csrf_exempt @@ -30,7 +24,6 @@ def outbox(request, username): min_id = request.GET.get('min_id') max_id = request.GET.get('max_id') - query_path = user.outbox + '?' # filters for use in the django queryset min/max filters = {} # params for the outbox page id @@ -41,39 +34,20 @@ def outbox(request, username): if max_id != None: params['max_id'] = max_id filters['id__lte'] = max_id - collection_id = query_path + urlencode(params) - messages = models.Activity.objects.filter( + page_id = user.outbox + '?' + urlencode(params) + statuses = models.Status.objects.filter( user=user, - activity_type__in=['Article', 'Note'], **filters - ).all()[:limit] + ).all()[:limit] - outbox_page = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': collection_id, - 'type': 'OrderedCollectionPage', - 'partOf': user.outbox, - 'orderedItems': [m.content for m in messages], - } - if max_id: - outbox_page['next'] = query_path + \ - urlencode({'min_id': max_id, 'page': 'true'}) - if min_id: - outbox_page['prev'] = query_path + \ - urlencode({'max_id': min_id, 'page': 'true'}) - return JsonResponse(outbox_page) + return JsonResponse( + activitypub.get_outbox_page(user, page_id, statuses, max_id, min_id) + ) # collection overview - size = models.Review.objects.filter(user=user).count() - return JsonResponse({ - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': '%s/outbox' % user.actor, - 'type': 'OrderedCollection', - 'totalItems': size, - 'first': '%s/outbox?page=true' % user.actor, - 'last': '%s/outbox?min_id=0&page=true' % user.actor - }) + size = models.Status.objects.filter(user=user).count() + return JsonResponse(activitypub.get_outbox(user, size)) def handle_account_search(query): @@ -97,127 +71,56 @@ def handle_account_search(query): def handle_outgoing_follow(user, to_follow): ''' someone local wants to follow someone ''' - uuid = uuid4() - activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'https://%s/%s' % (DOMAIN, str(uuid)), - 'summary': '', - 'type': 'Follow', - 'actor': user.actor, - 'object': to_follow.actor, - } - + activity = activitypub.get_follow_request(user, to_follow) errors = broadcast(user, activity, [to_follow.inbox]) for error in errors: - # TODO: following masto users is returning 400 raise(error['error']) -def handle_outgoing_accept(user, to_follow, activity): +def handle_outgoing_accept(user, to_follow, request_activity): ''' send an acceptance message to a follow request ''' to_follow.followers.add(user) - activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'https://%s/%s#accepts/follows/' % (DOMAIN, to_follow.localname), - 'type': 'Accept', - 'actor': to_follow.actor, - 'object': activity, - } - recipient = get_recipients( - to_follow, - 'direct', - direct_recipients=[user] - ) + activity = activitypub.get_accept(to_follow, request_activity) + recipient = get_recipients(to_follow, 'direct', direct_recipients=[user]) broadcast(to_follow, activity, recipient) def handle_shelve(user, book, shelf): ''' a local user is getting a book put on their shelf ''' # update the database + # TODO: this should probably happen in incoming instead models.ShelfBook(book=book, shelf=shelf, added_by=user).save() - # send out the activitypub action - summary = '%s marked %s as %s' % ( - user.username, - book.data['title'], - shelf.name - ) - - uuid = uuid4() - activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': str(uuid), - 'summary': summary, - 'type': 'Add', - 'actor': user.actor, - 'object': { - 'type': 'Document', - 'name': book.data['title'], - 'url': book.openlibrary_key - }, - 'target': { - 'type': 'Collection', - 'name': shelf.name, - 'id': 'https://%s/user/%s/shelf/%s' % \ - (DOMAIN, user.localname, shelf.identifier) - } - } + activity = activitypub.get_add(user, book, shelf) recipients = get_recipients(user, 'public') - - models.ShelveActivity( - uuid=uuid, - user=user, - content=activity, - shelf=shelf, - book=book, - ).save() - broadcast(user, activity, recipients) + # tell the world about this cool thing that happened + verb = { + 'to-read': 'wants to read', + 'reading': 'started reading', + 'read': 'finished reading' + }[shelf.identifier] + name = user.name if user.name else user.localname + message = '%s %s %s' % (name, verb, book.data['title']) + status = create_status(user, message, mention_books=[book]) + + activity = activitypub.get_status(status) + create_activity = activitypub.get_create(user, activity) + + broadcast(user, create_activity, recipients) + def handle_unshelve(user, book, shelf): ''' a local user is getting a book put on their shelf ''' # update the database + # TODO: this should probably happen in incoming instead row = models.ShelfBook.objects.get(book=book, shelf=shelf) row.delete() - # send out the activitypub action - summary = '%s removed %s from %s' % ( - user.username, - book.data['title'], - shelf.name - ) - - uuid = uuid4() - activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': str(uuid), - 'summary': summary, - 'type': 'Remove', - 'actor': user.actor, - 'object': { - 'type': 'Document', - 'name': book.data['title'], - 'url': book.openlibrary_key - }, - 'target': { - 'type': 'Collection', - 'name': shelf.name, - 'id': 'https://%s/user/%s/shelf/%s' % \ - (DOMAIN, user.localname, shelf.identifier) - } - } + activity = activitypub.get_remove(user, book, shelf) recipients = get_recipients(user, 'public') - models.ShelveActivity( - uuid=uuid, - user=user, - content=activity, - shelf=shelf, - book=book, - activity_type='Remove', - ).save() - broadcast(user, activity, recipients) @@ -226,63 +129,25 @@ def handle_review(user, book, name, content, rating): # validated and saves the review in the database so it has an id review = create_review(user, book, name, content, rating) - review_path = 'https://%s/user/%s/status/%d' % \ - (DOMAIN, user.localname, review.id) - book_path = 'https://%s/book/%s' % (DOMAIN, review.book.openlibrary_key) + review_activity = activitypub.get_review(review) + review_create_activity = activitypub.get_create(user, review_activity) + fr_recipients = get_recipients(user, 'public', limit='fedireads') + broadcast(user, review_create_activity, fr_recipients) - now = datetime.utcnow().isoformat() #TODO: should this be http_date? - review_activity = { - 'id': review_path, - 'url': review_path, - 'inReplyTo': book_path, - 'published': now, - 'attributedTo': user.actor, - # TODO: again, assuming all posts are public - 'to': ['https://www.w3.org/ns/activitystreams#Public'], - 'cc': ['https://%s/user/%s/followers' % (DOMAIN, user.localname)], - 'sensitive': False, # TODO: allow content warning/sensitivity - 'content': content, - 'type': 'Note', - 'fedireadsType': 'Review', - 'name': name, - 'rating': rating, # fedireads-only custom field - 'attachment': [], # TODO: the book cover - 'replies': { - 'id': '%s/replies' % review_path, - 'type': 'Collection', - 'first': { - 'type': 'CollectionPage', - 'next': '%s/replies?only_other_accounts=true&page=true' % \ - review_path, - 'partOf': '%s/replies' % review_path, - 'items': [], # TODO: populate with replies - } - } - } - review.activity = review_activity - review.save() + # re-format the activity for non-fedireads servers + article_activity = activitypub.get_review_article(review) + article_create_activity = activitypub.get_create(user, article_activity) - signer = pkcs1_15.new(RSA.import_key(user.private_key)) - signed_message = signer.sign(SHA256.new(content.encode('utf8'))) - create_activity = { - '@context': 'https://www.w3.org/ns/activitystreams', + other_recipients = get_recipients(user, 'public', limit='other') + broadcast(user, article_create_activity, other_recipients) - 'id': '%s/activity' % review_path, - 'type': 'Create', - 'actor': user.actor, - 'published': now, - 'to': ['%s/followers' % user.actor], - 'cc': ['https://www.w3.org/ns/activitystreams#Public'], - - 'object': review_activity, - 'signature': { - 'type': 'RsaSignature2017', - 'creator': 'https://%s/user/%s#main-key' % (DOMAIN, user.localname), - 'created': now, - 'signatureValue': b64encode(signed_message).decode('utf8'), - } - } +def handle_comment(user, review, content): + ''' post a review ''' + # validated and saves the comment in the database so it has an id + comment = create_status(user, content, reply_parent=review) + comment_activity = activitypub.get_status(comment) + create_activity = activitypub.get_create(user, comment_activity) recipients = get_recipients(user, 'public') broadcast(user, create_activity, recipients) diff --git a/fedireads/remote_user.py b/fedireads/remote_user.py index 80e5a53b..013358b1 100644 --- a/fedireads/remote_user.py +++ b/fedireads/remote_user.py @@ -41,7 +41,8 @@ def get_or_create_remote_user(actor): shared_inbox=shared_inbox, # TODO: I'm never actually using this for remote users public_key=data.get('publicKey').get('publicKeyPem'), - local=False + local=False, + fedireads_user=False, ) except KeyError: return False diff --git a/fedireads/activity.py b/fedireads/status.py similarity index 50% rename from fedireads/activity.py rename to fedireads/status.py index a50b585d..2114e94d 100644 --- a/fedireads/activity.py +++ b/fedireads/status.py @@ -9,15 +9,12 @@ def create_review(user, possible_book, name, content, rating): # throws a value error if the book is not found book = get_or_create_book(possible_book) - # sanitize review html - parser = InputHtmlParser() - parser.feed(content) - content = parser.get_output() + content = sanitize(content) # no ratings outside of 0-5 rating = rating if 0 <= rating <= 5 else 0 - review = models.Review.objects.create( + return models.Review.objects.create( user=user, book=book, name=name, @@ -25,6 +22,31 @@ def create_review(user, possible_book, name, content, rating): content=content, ) - return review + +def create_status(user, content, reply_parent=None, mention_books=None): + ''' a status update ''' + # TODO: handle @'ing users + + # sanitize input html + parser = InputHtmlParser() + parser.feed(content) + content = parser.get_output() + + status = models.Status.objects.create( + user=user, + content=content, + reply_parent=reply_parent, + ) + + if mention_books: + for book in mention_books: + status.mention_books.add(book) + + return status +def sanitize(content): + ''' remove invalid html from free text ''' + parser = InputHtmlParser() + parser.feed(content) + return parser.get_output() diff --git a/fedireads/templates/feed.html b/fedireads/templates/feed.html index 6ecb6536..f18e107a 100644 --- a/fedireads/templates/feed.html +++ b/fedireads/templates/feed.html @@ -55,40 +55,34 @@
{{ activity.content.rating | stars }}
-{{ activity.content.content | safe }}
+{{ activity.rating | stars }}
+{{ activity.content | safe }}