adding federation

This commit is contained in:
Mouse Reeve 2020-01-26 17:55:02 -08:00
parent e30e06c283
commit 6b85d8838f
11 changed files with 244 additions and 83 deletions

View file

@ -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
}
}

View file

@ -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

View file

@ -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')},
),
]

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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;

View file

@ -9,7 +9,7 @@
<p class="title">{{ book.data.title }}</p>
<p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p>
{% if shelf.type == 'reading' %}
<button>Done reading</button>
<button>done reading</button>
{% endif %}
</div>
{% endfor %}
@ -17,6 +17,21 @@
</div>
<div id="main">
<div class="carosel">
{% for book in recent_books %}
<div class="book-preview">
<img class="cover" src="static/images/small.jpg">
<p class="title">{{ book.data.title }}</p>
<p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p>
{% if not book.user_shelves %}
<form name="shelve" action="/shelve/{{ request.user.username }}_to-read/{{ book.id }}" method="post">
<input type="hidden" name="book" value="book.id"></input>
<input type="submit" value="want to read"></input>
</form>
{% endif %}
</div>
{% endfor %}
</div>
<div class="update">
<div class="user-preview">
<img class="user-pic" src="static/images/profile.jpg">

View file

@ -23,9 +23,10 @@ urlpatterns = [
path('login/', views.user_login),
path('logout/', views.user_logout),
path('user/<str:username>', views.user_profile),
path('shelve/<str:shelf_id>/<int:book_id>', views.shelve),
path('follow/', views.follow),
path('unfollow/', views.unfollow),
path('api/book/<str:olkey>', openlibrary.get_book),
path('api/u/<str:username>', federation.actor),
path('api/<str:username>/inbox', federation.inbox),
path('api/<str:username>/outbox', federation.outbox),
path('.well-known/webfinger', federation.webfinger),

View file

@ -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)

View file

@ -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