From e30e06c283b1a9caf9a719c1e5b38728b0e22aab Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 26 Jan 2020 12:14:27 -0800 Subject: [PATCH] Adds actor --- .../{activitystream.py => federation.py} | 31 +++++++- fedireads/migrations/0001_initial.py | 23 +++++- fedireads/models.py | 73 ++++++++++++++++--- fedireads/openlibrary.py | 2 + fedireads/settings.py | 2 +- fedireads/templates/layout.html | 4 +- fedireads/templates/user.html | 29 ++++++++ fedireads/urls.py | 9 ++- fedireads/views.py | 59 ++++++++++++++- rebuilddb.sh | 15 ++++ 10 files changed, 225 insertions(+), 22 deletions(-) rename fedireads/{activitystream.py => federation.py} (55%) create mode 100644 fedireads/templates/user.html create mode 100755 rebuilddb.sh diff --git a/fedireads/activitystream.py b/fedireads/federation.py similarity index 55% rename from fedireads/activitystream.py rename to fedireads/federation.py index 99ce4eeb5..9ab1eec51 100644 --- a/fedireads/activitystream.py +++ b/fedireads/federation.py @@ -1,8 +1,10 @@ ''' activitystream api ''' -from django.http import HttpResponseBadRequest, HttpResponseNotFound, JsonResponse +from django.http import HttpResponse, HttpResponseBadRequest, \ + HttpResponseNotFound, JsonResponse from fedireads.settings import DOMAIN from fedireads.models import User + def webfinger(request): ''' allow other servers to ask about a user ''' resource = request.GET.get('resource') @@ -15,7 +17,9 @@ def webfinger(request): return HttpResponseNotFound('No account found') return JsonResponse(format_webfinger(user)) + def format_webfinger(user): + ''' helper function to create structured webfinger json ''' return { 'subject': 'acct:%s@%s' % (user.username, DOMAIN), 'links': [ @@ -26,3 +30,28 @@ def format_webfinger(user): } ] } + +def inbox(request, username): + ''' incoming activitypub events ''' + # TODO RSA junk: signature = request.headers['Signature'] + user = User.objects.get(username=username) + + +def outbox(request, username): + user = User.objects.get(username=username) + if request.method == 'GET': + # list of activities + return JsonResponse() + + data = request.body.decode('utf-8') + if data.activity.type == 'Follow': + handle_follow(data) + return HttpResponse() + +def handle_follow(data): + pass + +def get_or_create_remote_user(activity): + pass + + diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index fcc00f97b..3d246dfe8 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-25 23:55 +# Generated by Django 2.0.13 on 2020-01-26 20:12 from django.conf import settings import django.contrib.auth.models @@ -32,9 +32,11 @@ 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=255)), - ('public_key', models.CharField(max_length=255)), + ('private_key', models.CharField(max_length=1024)), + ('public_key', models.CharField(max_length=1024)), ('api_key', models.CharField(blank=True, max_length=255, null=True)), + ('actor', django.contrib.postgres.fields.jsonb.JSONField()), + ('local', models.BooleanField(default=True)), ('created_date', models.DateTimeField(auto_now_add=True)), ('updated_date', models.DateTimeField(auto_now=True)), ('followers', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), @@ -50,6 +52,16 @@ 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=[ @@ -129,6 +141,11 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name='book', + name='shelves', + field=models.ManyToManyField(through='fedireads.ShelfBook', to='fedireads.Shelf'), + ), migrations.AddField( model_name='book', name='works', diff --git a/fedireads/models.py b/fedireads/models.py index b32a0621e..8692ca483 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -5,13 +5,15 @@ from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import JSONField from Crypto.PublicKey import RSA from Crypto import Random -from datetime import datetime +from fedireads.settings import DOMAIN class User(AbstractUser): ''' a user who wants to read books ''' - private_key = models.CharField(max_length=255) - public_key = models.CharField(max_length=255) + private_key = models.CharField(max_length=1024) + public_key = models.CharField(max_length=1024) api_key = models.CharField(max_length=255, blank=True, null=True) + actor = JSONField() + local = models.BooleanField(default=True) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) followers = models.ManyToManyField('self', symmetrical=False) @@ -21,16 +23,36 @@ class User(AbstractUser): if not self.private_key: random_generator = Random.new().read key = RSA.generate(1024, random_generator) - self.private_key = key - self.public_key = key.publickey() - if not self.id: - self.created_date = datetime.now() - self.updated_date = datetime.now() + self.private_key = key.export_key() + self.public_key = key.publickey().export_key() + + if self.local and not self.actor: + self.actor = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + + 'id': 'https://%s/u/%s' % (DOMAIN, self.username), + 'type': 'Person', + 'preferredUsername': self.username, + 'inbox': 'https://%s/api/inbox' % DOMAIN, + 'followers': 'https://%s/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'), + } + } super().save(*args, **kwargs) + @receiver(models.signals.post_save, sender=User) def execute_after_save(sender, instance, created, *args, **kwargs): + ''' create shelves for new users ''' + # TODO: how are remote users handled? what if they aren't readers? if not created: return shelves = [{ @@ -45,7 +67,12 @@ def execute_after_save(sender, instance, created, *args, **kwargs): }] for shelf in shelves: - Shelf(name=shelf['name'], shelf_type=shelf['type'], user=instance, editable=False).save() + Shelf( + name=shelf['name'], + shelf_type=shelf['type'], + user=instance, + editable=False + ).save() class Message(models.Model): @@ -65,6 +92,13 @@ class Review(Message): 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): name = models.CharField(max_length=100) user = models.ForeignKey('User', on_delete=models.PROTECT) @@ -84,7 +118,12 @@ class ShelfBook(models.Model): # 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) - added_by = models.ForeignKey('User', blank=True, null=True, on_delete=models.PROTECT) + added_by = models.ForeignKey( + 'User', + blank=True, + null=True, + on_delete=models.PROTECT + ) added_date = models.DateTimeField(auto_now_add=True) @@ -94,11 +133,23 @@ class Book(models.Model): data = JSONField() works = models.ManyToManyField('Work') authors = models.ManyToManyField('Author') - added_by = models.ForeignKey('User', on_delete=models.PROTECT, blank=True, null=True) + shelves = models.ManyToManyField( + 'Shelf', + symmetrical=False, + through='ShelfBook', + through_fields=('book', 'shelf') + ) + added_by = models.ForeignKey( + 'User', + blank=True, + null=True, + on_delete=models.PROTECT + ) added_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) class Work(models.Model): + ''' encompassses all editions of a book ''' openlibary_key = models.CharField(max_length=255) data = JSONField() added_date = models.DateTimeField(auto_now_add=True) diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index 66fc20bbf..c60a63ee1 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -18,6 +18,8 @@ def get_book(request, olkey): 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 book.save() for work_id in data['works']: work_id = work_id['key'] diff --git a/fedireads/settings.py b/fedireads/settings.py index 1359408c7..98b4cb2a5 100644 --- a/fedireads/settings.py +++ b/fedireads/settings.py @@ -87,7 +87,7 @@ DATABASES = { } } -LOGIN_URL = 'login/' +LOGIN_URL = '/login/' AUTH_USER_MODEL = 'fedireads.User' # Password validation diff --git a/fedireads/templates/layout.html b/fedireads/templates/layout.html index c1329102a..0bc4d3a78 100644 --- a/fedireads/templates/layout.html +++ b/fedireads/templates/layout.html @@ -21,12 +21,12 @@
-
📚FediReads
+
{% if user.is_authenticated %}
- Welcome, {{ user.username }} + Welcome, {{ request.user.username }}
{% else %} diff --git a/fedireads/templates/user.html b/fedireads/templates/user.html new file mode 100644 index 000000000..09c6dbec6 --- /dev/null +++ b/fedireads/templates/user.html @@ -0,0 +1,29 @@ +{% extends 'layout.html' %} +{% block content %} +
+ + {% for book in books.all %} +
+ {{ book.data.title }} by {{ book.authors.first.data.name }} +
+ {% endfor %} + +
+{% endblock %} diff --git a/fedireads/urls.py b/fedireads/urls.py index cc145cb44..1f19b063c 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -15,13 +15,18 @@ Including another URLconf """ from django.contrib import admin from django.urls import path -from fedireads import activitystream, openlibrary, views +from fedireads import federation, openlibrary, views urlpatterns = [ path('admin/', admin.site.urls), path('', views.home), path('login/', views.user_login), path('logout/', views.user_logout), + path('user/', views.user_profile), + path('follow/', views.follow), + path('unfollow/', views.unfollow), path('api/book/', openlibrary.get_book), - path('.well-known/webfinger', activitystream.webfinger), + 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 7b2e360c2..9f3463c79 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -4,12 +4,12 @@ from django.contrib.auth import authenticate, login, logout from django.shortcuts import redirect from django.template.response import TemplateResponse from django.views.decorators.csrf import csrf_exempt -from fedireads.models import Shelf +from fedireads import models @login_required def home(request): ''' user feed ''' - shelves = Shelf.objects.filter(user=request.user.id) + shelves = models.Shelf.objects.filter(user=request.user.id) data = { 'user': request.user, 'shelves': shelves, @@ -35,5 +35,60 @@ def user_login(request): @csrf_exempt @login_required def user_logout(request): + ''' done with this place! outa here! ''' logout(request) return redirect('/') + + +@login_required +def user_profile(request, username): + ''' profile page for a user ''' + user = models.User.objects.get(username=username) + books = models.Book.objects.filter(shelves__user=user) + following = user.followers.filter(id=request.user.id).count() > 0 + data = { + 'user': user, + 'books': books, + 'is_self': request.user.id == user.id, + 'following': following, + } + return TemplateResponse(request, 'user.html', data) + + +@csrf_exempt +@login_required +def follow(request): + followed = request.POST.get('user') + followed = models.User.objects.get(id=followed) + followed.followers.add(request.user) + activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'summary': '', + 'type': 'Follow', + 'actor': { + 'type': 'Person', + 'name': request.user.get_actor(), + }, + 'object': { + 'type': 'Person', + 'name': followed.get_actor(), + } + } + + models.Activity( + data=activity, + user=request.user, + ) + + return redirect('/user/%s' % followed.username) + + +@csrf_exempt +@login_required +def unfollow(request): + 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 new file mode 100755 index 000000000..2bf7d701a --- /dev/null +++ b/rebuilddb.sh @@ -0,0 +1,15 @@ +#!/bin/bash +rm fedireads/migrations/0* +set -e +dropdb fedireads +createdb fedireads +python manage.py makemigrations fedireads +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