From 6b85d8838fef51b4aae8b5e0c9b0a4b81e64b238 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 26 Jan 2020 17:55:02 -0800 Subject: [PATCH] adding federation --- fedireads/activitypub_templates.py | 29 +++++++++ fedireads/federation.py | 89 ++++++++++++++++++++++++---- fedireads/migrations/0001_initial.py | 44 +++++--------- fedireads/models.py | 63 ++++++++++++-------- fedireads/openlibrary.py | 25 ++++---- fedireads/settings.py | 5 +- fedireads/static/format.css | 9 +++ fedireads/templates/feed.html | 17 +++++- fedireads/urls.py | 3 +- fedireads/views.py | 33 ++++++++++- rebuilddb.sh | 10 ++-- 11 files changed, 244 insertions(+), 83 deletions(-) create mode 100644 fedireads/activitypub_templates.py diff --git a/fedireads/activitypub_templates.py b/fedireads/activitypub_templates.py new file mode 100644 index 000000000..8887e6f1d --- /dev/null +++ b/fedireads/activitypub_templates.py @@ -0,0 +1,29 @@ +''' generates activitypub formatted objects ''' + +def shelve_action(user, book, shelf): + ''' a user puts a book on a shelf. + activitypub action type Add + https://www.w3.org/ns/activitystreams#Add ''' + book_title = book.data['title'] + summary = '%s added %s to %s' % ( + user.username, + book_title, + shelf.name + ) + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'summary': summary, + 'type': 'Add', + 'actor': user.activitypub_id, + 'object': { + 'type': 'Document', + 'name': book_title, + 'url': book.openlibary_key + }, + 'target': { + 'type': 'Collection', + 'name': shelf.name, + 'id': shelf.activitypub_id + } + } + diff --git a/fedireads/federation.py b/fedireads/federation.py index 9ab1eec51..e2d92708d 100644 --- a/fedireads/federation.py +++ b/fedireads/federation.py @@ -1,18 +1,24 @@ ''' activitystream api ''' +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 HttpResponse, HttpResponseBadRequest, \ HttpResponseNotFound, JsonResponse +from django.views.decorators.csrf import csrf_exempt from fedireads.settings import DOMAIN -from fedireads.models import User - +from fedireads import models +from fedireads import openlibrary +import requests def webfinger(request): ''' allow other servers to ask about a user ''' resource = request.GET.get('resource') if not resource and not resource.startswith('acct:'): return HttpResponseBadRequest() - account = resource.replace('acct:', '') - account = account.replace('@' + DOMAIN, '') - user = User.objects.filter(username=account).first() + ap_id = resource.replace('acct:', '') + user = models.User.objects.filter(activitypub_id=ap_id).first() if not user: return HttpResponseNotFound('No account found') return JsonResponse(format_webfinger(user)) @@ -21,24 +27,52 @@ def webfinger(request): def format_webfinger(user): ''' helper function to create structured webfinger json ''' return { - 'subject': 'acct:%s@%s' % (user.username, DOMAIN), + 'subject': 'acct:%s' % (user.activitypub_id), 'links': [ { 'rel': 'self', 'type': 'application/activity+json', - 'href': 'https://%s/user/%s' % (DOMAIN, user.username), + 'href': user.actor['id'] } ] } + +@csrf_exempt +def actor(request, username): + ''' return an activitypub actor object ''' + user = models.User.objects.get(username=username) + return JsonResponse(user.actor) + + +@csrf_exempt def inbox(request, username): ''' incoming activitypub events ''' - # TODO RSA junk: signature = request.headers['Signature'] - user = User.objects.get(username=username) + if request.method == 'GET': + # return a collection of something? + pass + activity = request.POST.dict() + if activity['type'] == 'Add': + handle_add(activity) + return HttpResponse() + +def handle_add(activity): + 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(activitypub_id=user_ap_id) + shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id']) + models.ShelfBook( + shelf=shelf, + book=book, + added_by=user, + ).save() + +@csrf_exempt def outbox(request, username): - user = User.objects.get(username=username) + user = models.User.objects.get(username=username) if request.method == 'GET': # list of activities return JsonResponse() @@ -48,6 +82,41 @@ def outbox(request, username): handle_follow(data) return HttpResponse() + +def broadcast_action(sender, action, recipients): + ''' sign and send out the actions ''' + #models.Message( + # author=sender, + # content=action + #).save() + for recipient in recipients: + action['to'] = 'https://www.w3.org/ns/activitystreams#Public' + action['cc'] = [recipient] + + inbox_fragment = sender.actor['inbox'].replace('https://' + DOMAIN, '') + now = datetime.utcnow().isoformat() + message_to_sign = '''(request-target): post %s +host: https://%s +date: %s''' % (inbox_fragment, DOMAIN, now) + signer = pkcs1_15.new(RSA.import_key(sender.private_key)) + signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) + + signature = 'keyId="%s",' % sender.activitypub_id + signature += 'headers="(request-target) host date",' + signature += 'signature="%s"' % b64encode(signed_message) + response = requests.post( + recipient, + data=action, + headers={ + 'Date': now, + 'Signature': signature, + 'Host': DOMAIN, + 'Content-Type': 'application/json', + }, + ) + if not response.ok: + return response.raise_for_status() + def handle_follow(data): pass diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index 3d246dfe8..48ca9c1ff 100644 --- a/fedireads/migrations/0001_initial.py +++ b/fedireads/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.13 on 2020-01-26 20:12 +# Generated by Django 2.0.13 on 2020-01-27 01:47 from django.conf import settings import django.contrib.auth.models @@ -32,8 +32,9 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('private_key', models.CharField(max_length=1024)), - ('public_key', models.CharField(max_length=1024)), + ('activitypub_id', models.CharField(max_length=255)), + ('private_key', models.TextField(blank=True, null=True)), + ('public_key', models.TextField()), ('api_key', models.CharField(blank=True, max_length=255, null=True)), ('actor', django.contrib.postgres.fields.jsonb.JSONField()), ('local', models.BooleanField(default=True)), @@ -52,16 +53,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')), - ('data', django.contrib.postgres.fields.jsonb.JSONField()), - ('remote', models.BooleanField(default=False)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='Author', fields=[ @@ -76,6 +67,7 @@ class Migration(migrations.Migration): name='Book', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('activitypub_id', models.CharField(max_length=255)), ('openlibary_key', models.CharField(max_length=255)), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('added_date', models.DateTimeField(auto_now_add=True)), @@ -84,26 +76,12 @@ class Migration(migrations.Migration): ('authors', models.ManyToManyField(to='fedireads.Author')), ], ), - migrations.CreateModel( - name='Review', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('content', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('star_rating', models.IntegerField(default=0)), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), - ], - options={ - 'abstract': False, - }, - ), migrations.CreateModel( name='Shelf', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('activitypub_id', models.CharField(max_length=255)), + ('identifier', models.CharField(max_length=255)), ('name', models.CharField(max_length=100)), ('editable', models.BooleanField(default=True)), ('shelf_type', models.CharField(default='custom', max_length=100)), @@ -151,4 +129,12 @@ class Migration(migrations.Migration): name='works', field=models.ManyToManyField(to='fedireads.Work'), ), + migrations.AlterUniqueTogether( + name='shelfbook', + unique_together={('book', 'shelf')}, + ), + migrations.AlterUniqueTogether( + name='shelf', + unique_together={('user', 'name')}, + ), ] diff --git a/fedireads/models.py b/fedireads/models.py index 8692ca483..8df1ea0a6 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -5,12 +5,14 @@ from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import JSONField from Crypto.PublicKey import RSA from Crypto import Random -from fedireads.settings import DOMAIN +from fedireads.settings import DOMAIN, OL_URL +import re class User(AbstractUser): ''' a user who wants to read books ''' - private_key = models.CharField(max_length=1024) - public_key = models.CharField(max_length=1024) + activitypub_id = models.CharField(max_length=255) + private_key = models.TextField(blank=True, null=True) + public_key = models.TextField() api_key = models.CharField(max_length=255, blank=True, null=True) actor = JSONField() local = models.BooleanField(default=True) @@ -23,8 +25,8 @@ class User(AbstractUser): if not self.private_key: random_generator = Random.new().read key = RSA.generate(1024, random_generator) - self.private_key = key.export_key() - self.public_key = key.publickey().export_key() + self.private_key = key.export_key().decode('utf8') + self.public_key = key.publickey().export_key().decode('utf8') if self.local and not self.actor: self.actor = { @@ -33,19 +35,22 @@ class User(AbstractUser): 'https://w3id.org/security/v1' ], - 'id': 'https://%s/u/%s' % (DOMAIN, self.username), + 'id': 'https://%s/api/u/%s' % (DOMAIN, self.username), 'type': 'Person', 'preferredUsername': self.username, - 'inbox': 'https://%s/api/inbox' % DOMAIN, - 'followers': 'https://%s/u/%s/followers' % \ + 'inbox': 'https://%s/api/%s/inbox' % (DOMAIN, self.username), + 'followers': 'https://%s/api/u/%s/followers' % \ (DOMAIN, self.username), 'publicKey': { - 'id': 'https://%s/u/%s#main-key' % (DOMAIN, self.username), - 'owner': 'https://%s/u/%s' % (DOMAIN, self.username), - 'publicKeyPem': self.public_key.decode('utf8'), + 'id': 'https://%s/api/u/%s#main-key' % (DOMAIN, self.username), + 'owner': 'https://%s/api/u/%s' % (DOMAIN, self.username), + 'publicKeyPem': self.public_key, } } + if not self.activitypub_id: + self.activitypub_id = '%s@%s' % (self.username, DOMAIN) + super().save(*args, **kwargs) @@ -78,7 +83,6 @@ def execute_after_save(sender, instance, created, *args, **kwargs): class Message(models.Model): ''' any kind of user post, incl. reviews, replies, and status updates ''' author = models.ForeignKey('User', on_delete=models.PROTECT) - name = models.CharField(max_length=255) content = JSONField(max_length=5000) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) @@ -87,19 +91,9 @@ class Message(models.Model): abstract = True -class Review(Message): - book = models.ForeignKey('Book', on_delete=models.PROTECT) - star_rating = models.IntegerField(default=0) - - -class Activity(models.Model): - data = JSONField() - user = models.ForeignKey('User', on_delete=models.PROTECT) - remote = models.BooleanField(default=False) - created_date = models.DateTimeField(auto_now_add=True) - - class Shelf(models.Model): + activitypub_id = models.CharField(max_length=255) + identifier = models.CharField(max_length=255) name = models.CharField(max_length=100) user = models.ForeignKey('User', on_delete=models.PROTECT) editable = models.BooleanField(default=True) @@ -113,6 +107,19 @@ class Shelf(models.Model): created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) + class Meta: + unique_together = ('user', 'name') + + def save(self, *args, **kwargs): + if not self.identifier: + self.identifier = '%s_%s' % ( + self.user.username, + re.sub(r'\W', '-', self.name).lower() + ) + if not self.activitypub_id: + self.activitypub_id = 'https://%s/shelf/%s' % (DOMAIN, self.identifier) + super().save(*args, **kwargs) + class ShelfBook(models.Model): # many to many join table for books and shelves @@ -125,10 +132,13 @@ class ShelfBook(models.Model): on_delete=models.PROTECT ) added_date = models.DateTimeField(auto_now_add=True) + class Meta: + unique_together = ('book', 'shelf') class Book(models.Model): ''' a non-canonical copy from open library ''' + activitypub_id = models.CharField(max_length=255) openlibary_key = models.CharField(max_length=255) data = JSONField() works = models.ManyToManyField('Work') @@ -148,6 +158,11 @@ class Book(models.Model): added_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) + def save(self, *args, **kwargs): + self.activitypub_id = '%s%s' % (OL_URL, self.openlibary_key) + super().save(*args, **kwargs) + + class Work(models.Model): ''' encompassses all editions of a book ''' openlibary_key = models.CharField(max_length=255) diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index c60a63ee1..e9d4b0bfa 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -1,25 +1,26 @@ ''' activitystream api and books ''' -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest from django.core.exceptions import ObjectDoesNotExist -from django.core import serializers from fedireads.models import Author, Book, Work +from fedireads.settings import OL_URL import requests -openlibrary_url = 'https://openlibrary.org' - -def get_book(request, olkey): +def get_or_create_book(olkey, user=None, update=True): + ''' add a book ''' # check if this is a valid open library key, and a book - response = requests.get(openlibrary_url + '/book/' + olkey + '.json') + olkey = '/book/' + olkey + response = requests.get(OL_URL + olkey + '.json') # get the existing entry from our db, if it exists try: book = Book.objects.get(openlibary_key=olkey) + if not update: + return book except ObjectDoesNotExist: book = Book(openlibary_key=olkey) data = response.json() book.data = data - if request and request.user and request.user.is_authenticated: - book.added_by = request.user + if user and user.is_authenticated: + book.added_by = user book.save() for work_id in data['works']: work_id = work_id['key'] @@ -27,23 +28,25 @@ def get_book(request, olkey): for author_id in data['authors']: author_id = author_id['key'] book.authors.add(get_or_create_author(author_id)) - return HttpResponse(serializers.serialize('json', [book])) + return book def get_or_create_work(olkey): + ''' load em up ''' try: work = Work.objects.get(openlibary_key=olkey) except ObjectDoesNotExist: - response = requests.get(openlibrary_url + olkey + '.json') + response = requests.get(OL_URL + olkey + '.json') data = response.json() work = Work(openlibary_key=olkey, data=data) work.save() return work def get_or_create_author(olkey): + ''' load that author ''' try: author = Author.objects.get(openlibary_key=olkey) except ObjectDoesNotExist: - response = requests.get(openlibrary_url + olkey + '.json') + response = requests.get(OL_URL + olkey + '.json') data = response.json() author = Author(openlibary_key=olkey, data=data) author.save() diff --git a/fedireads/settings.py b/fedireads/settings.py index 98b4cb2a5..672772f1c 100644 --- a/fedireads/settings.py +++ b/fedireads/settings.py @@ -25,9 +25,10 @@ SECRET_KEY = '7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['localhost', 'ff2cb3e9.ngrok.io'] -DOMAIN = 'localhost' +DOMAIN = 'ff2cb3e9.ngrok.io' +OL_URL = 'https://openlibrary.org' # Application definition diff --git a/fedireads/static/format.css b/fedireads/static/format.css index 7e0ea6c51..5f52ee6a0 100644 --- a/fedireads/static/format.css +++ b/fedireads/static/format.css @@ -37,6 +37,15 @@ header > div:last-child { float: left; } +.carosel { + margin-bottom: 1rem; +} +.carosel > div { + display: inline-block; + margin: 0 1rem; + vertical-align: top; +} + .user-pic { width: 2em; height: auto; diff --git a/fedireads/templates/feed.html b/fedireads/templates/feed.html index 1d0c7f458..63fa70796 100644 --- a/fedireads/templates/feed.html +++ b/fedireads/templates/feed.html @@ -9,7 +9,7 @@

{{ book.data.title }}

by {{ book.authors.first.data.name }}

{% if shelf.type == 'reading' %} - + {% endif %} {% endfor %} @@ -17,6 +17,21 @@
+
+ {% for book in recent_books %} +
+ +

{{ book.data.title }}

+

by {{ book.authors.first.data.name }}

+ {% if not book.user_shelves %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
diff --git a/fedireads/urls.py b/fedireads/urls.py index 1f19b063c..bee370fa8 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -23,9 +23,10 @@ urlpatterns = [ path('login/', views.user_login), path('logout/', views.user_logout), path('user/', views.user_profile), + path('shelve//', views.shelve), path('follow/', views.follow), path('unfollow/', views.unfollow), - path('api/book/', openlibrary.get_book), + path('api/u/', federation.actor), path('api//inbox', federation.inbox), path('api//outbox', federation.outbox), path('.well-known/webfinger', federation.webfinger), diff --git a/fedireads/views.py b/fedireads/views.py index 9f3463c79..bbf7c8f81 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -1,18 +1,30 @@ ''' application views/pages ''' from django.contrib.auth.decorators import login_required from django.contrib.auth import authenticate, login, logout +from django.db.models import FilteredRelation, Q from django.shortcuts import redirect from django.template.response import TemplateResponse from django.views.decorators.csrf import csrf_exempt from fedireads import models +import fedireads.activitypub_templates as templates +from fedireads.federation import broadcast_action @login_required def home(request): ''' user feed ''' shelves = models.Shelf.objects.filter(user=request.user.id) + recent_books = models.Book.objects.order_by( + 'added_date' + ).annotate( + user_shelves=FilteredRelation( + 'shelves', + condition=Q(shelves__user_id=request.user.id) + ) + ).values('id', 'authors', 'data', 'user_shelves') data = { 'user': request.user, 'shelves': shelves, + 'recent_books': recent_books, } return TemplateResponse(request, 'feed.html', data) @@ -55,9 +67,28 @@ def user_profile(request, username): return TemplateResponse(request, 'user.html', data) +@csrf_exempt +@login_required +def shelve(request, shelf_id, book_id): + ''' put a book on a user's shelf ''' + book = models.Book.objects.get(id=book_id) + shelf = models.Shelf.objects.get(identifier=shelf_id) + + # update the database + #models.ShelfBook(book=book, shelf=shelf, added_by=request.user).save() + + # send out the activitypub action + action = templates.shelve_action(request.user, book, shelf) + recipients = [u.actor['inbox'] for u in request.user.followers.all()] + broadcast_action(request.user, action, recipients) + + return redirect('/') + + @csrf_exempt @login_required def follow(request): + ''' follow another user, here or abroad ''' followed = request.POST.get('user') followed = models.User.objects.get(id=followed) followed.followers.add(request.user) @@ -86,9 +117,9 @@ def follow(request): @csrf_exempt @login_required def unfollow(request): + ''' unfollow a user ''' followed = request.POST.get('user') followed = models.User.objects.get(id=followed) followed.followers.remove(request.user) return redirect('/user/%s' % followed.username) - diff --git a/rebuilddb.sh b/rebuilddb.sh index 2bf7d701a..fa1c014f4 100755 --- a/rebuilddb.sh +++ b/rebuilddb.sh @@ -9,7 +9,9 @@ python manage.py migrate echo "from fedireads.models import User User.objects.create_user('mouse', 'mouse.reeve@gmail.com', 'password123')" | python manage.py shell echo "from fedireads.models import User -User.objects.create_user('rat', 'rat@rat.com', 'ratword')" | python manage.py shell -echo "from fedireads.openlibrary import get_book -get_book(None, 'OL13549170M') -get_book(None, 'OL24738110M')" | python manage.py shell +User.objects.create_user('rat', 'rat@rat.com', 'ratword') +User.objects.get(id=1).followers.add(User.objects.get(id=2))" | python manage.py shell +echo "from fedireads.openlibrary import get_or_create_book +get_or_create_book('OL13549170M') +get_or_create_book('OL24738110M')" | python manage.py shell +python manage.py runserver