Code cleanup

This commit is contained in:
Mouse Reeve 2020-01-28 11:45:27 -08:00
parent 31110f4b0c
commit 01464003d5
8 changed files with 156 additions and 144 deletions

View file

@ -1,5 +1,19 @@
''' api utilties '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from datetime import datetime
import json
import requests
from fedireads import models
from fedireads import incoming
from fedireads.settings import DOMAIN
def get_or_create_remote_user(actor): def get_or_create_remote_user(actor):
''' wow, a foreigner ''' ''' look up a remote user or add them '''
try: try:
user = models.User.objects.get(actor=actor) user = models.User.objects.get(actor=actor)
except models.User.DoesNotExist: except models.User.DoesNotExist:
@ -13,3 +27,56 @@ def get_or_create_remote_user(actor):
) )
return user return user
def get_recipients(user, post_privacy, direct_recipients=None):
''' deduplicated list of recipients '''
recipients = direct_recipients or []
followers = user.followers.all()
if post_privacy == 'public':
# post to public shared inboxes
shared_inboxes = set(u.shared_inbox for u in followers)
recipients += list(shared_inboxes)
# TODO: direct to anyone who's mentioned
if post_privacy == 'followers':
# don't send it to the shared inboxes
inboxes = set(u.inbox for u in followers)
recipients += list(inboxes)
# if post privacy is direct, we just have direct recipients,
# which is already set. hurray
return recipients
def broadcast(sender, action, recipients):
''' send out an event '''
for recipient in recipients:
# TODO: error handling
sign_and_send(sender, action, recipient)
def sign_and_send(sender, action, destination):
''' crpyto whatever and http junk '''
inbox_fragment = sender.inbox.replace('https://%s' % 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.localname
signature += 'headers="(request-target) host date",'
signature += 'signature="%s"' % b64encode(signed_message)
response = requests.post(
destination,
data=json.dumps(action),
headers={
'Date': now,
'Signature': signature,
'Host': DOMAIN,
},
)
if not response.ok:
response.raise_for_status()
incoming.handle_response(response)

View file

@ -1,14 +1,16 @@
''' activitystream api ''' ''' handles all of the activity coming in to the server '''
from django.http import HttpResponse, HttpResponseBadRequest, \ from django.http import HttpResponse, HttpResponseBadRequest, \
HttpResponseNotFound, JsonResponse HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from fedireads.settings import DOMAIN
from fedireads.openlibrary import get_or_create_book
from fedireads import models
from fedireads.api import get_or_create_remote_user
import json import json
from uuid import uuid4 from uuid import uuid4
from fedireads import models
from fedireads.api import get_or_create_remote_user
from fedireads.openlibrary import get_or_create_book
from fedireads.settings import DOMAIN
def webfinger(request): def webfinger(request):
''' allow other servers to ask about a user ''' ''' allow other servers to ask about a user '''
resource = request.GET.get('resource') resource = request.GET.get('resource')
@ -206,3 +208,34 @@ def handle_incoming_create(activity):
activity_type=activity['object']['type'] activity_type=activity['object']['type']
) )
return HttpResponse() return HttpResponse()
def handle_incoming_accept(activity):
''' someone is accepting a follow request '''
# our local user
user = models.User.objects.get(actor=activity['actor'])
# the person our local user wants to follow, who said yes
followed = get_or_create_remote_user(activity['object']['actor'])
# 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()
def handle_response(response):
''' hopefully it's an accept from our follow request '''
try:
activity = response.json()
except ValueError:
return
if activity['type'] == 'Accept':
handle_incoming_accept(activity)

View file

@ -1,4 +1,4 @@
# Generated by Django 3.0.2 on 2020-01-28 19:06 # Generated by Django 3.0.2 on 2020-01-28 19:39
from django.conf import settings from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
@ -65,6 +65,7 @@ class Migration(migrations.Migration):
('uuid', models.CharField(max_length=255, unique=True)), ('uuid', models.CharField(max_length=255, unique=True)),
('content', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)), ('content', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)),
('activity_type', models.CharField(max_length=255)), ('activity_type', models.CharField(max_length=255)),
('fedireads_type', models.CharField(blank=True, max_length=255, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)), ('updated_date', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),

View file

@ -3,11 +3,13 @@ from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from Crypto.PublicKey import RSA
from Crypto import Random from Crypto import Random
from fedireads.settings import DOMAIN, OL_URL from Crypto.PublicKey import RSA
import re import re
from fedireads.settings import DOMAIN, OL_URL
class User(AbstractUser): class User(AbstractUser):
''' a user who wants to read books ''' ''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True) private_key = models.TextField(blank=True, null=True)
@ -91,7 +93,10 @@ class Activity(models.Model):
uuid = models.CharField(max_length=255, unique=True) uuid = models.CharField(max_length=255, unique=True)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
content = JSONField(max_length=5000) content = JSONField(max_length=5000)
# the activitypub activity type (Create, Add, Follow, ...)
activity_type = models.CharField(max_length=255) 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)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
@ -101,6 +106,12 @@ class ShelveActivity(Activity):
book = models.ForeignKey('Book', on_delete=models.PROTECT) book = models.ForeignKey('Book', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', 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'
shelf.fedireads_type = 'Shelve'
super().save(*args, **kwargs)
class FollowActivity(Activity): class FollowActivity(Activity):
''' record follow requests sent out ''' ''' record follow requests sent out '''
@ -121,12 +132,14 @@ class Review(Activity):
book = models.ForeignKey('Book', on_delete=models.PROTECT) book = models.ForeignKey('Book', on_delete=models.PROTECT)
work = models.ForeignKey('Work', on_delete=models.PROTECT) work = models.ForeignKey('Work', on_delete=models.PROTECT)
name = models.TextField() name = models.TextField()
# TODO: validation
rating = models.IntegerField(default=0) rating = models.IntegerField(default=0)
review_content = models.TextField() review_content = models.TextField()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.activity_type: if not self.activity_type:
self.activity_type = 'Article' self.activity_type = 'Article'
self.fedireads_type = 'Review'
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -1,18 +1,14 @@
''' activitystream api ''' ''' 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 datetime import datetime
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from fedireads.settings import DOMAIN
from fedireads import models
from fedireads.api import get_or_create_remote_user
import json
import requests import requests
from uuid import uuid4 from uuid import uuid4
from fedireads import models
from fedireads.api import get_or_create_remote_user, get_recipients, \
broadcast
@csrf_exempt @csrf_exempt
def outbox(request, username): def outbox(request, username):
@ -69,35 +65,6 @@ def handle_outgoing_follow(user, to_follow):
broadcast(user, activity, [to_follow.inbox]) broadcast(user, activity, [to_follow.inbox])
def handle_response(response):
''' hopefully it's an accept from our follow request '''
try:
activity = response.json()
except ValueError:
return
if activity['type'] == 'Accept':
handle_incoming_accept(activity)
def handle_incoming_accept(activity):
''' someone is accepting a follow request '''
# our local user
user = models.User.objects.get(actor=activity['actor'])
# the person our local user wants to follow, who said yes
followed = get_or_create_remote_user(activity['object']['actor'])
# 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()
def handle_shelve(user, book, shelf): def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf ''' ''' a local user is getting a book put on their shelf '''
# update the database # update the database
@ -142,26 +109,6 @@ def handle_shelve(user, book, shelf):
broadcast(user, activity, recipients) broadcast(user, activity, recipients)
def get_recipients(user, post_privacy, direct_recipients=None):
''' deduplicated list of recipients '''
recipients = direct_recipients or []
followers = user.followers.all()
if post_privacy == 'public':
# post to public shared inboxes
shared_inboxes = set(u.shared_inbox for u in followers)
recipients += list(shared_inboxes)
# TODO: direct to anyone who's mentioned
if post_privacy == 'followers':
# don't send it to the shared inboxes
inboxes = set(u.inbox for u in followers)
recipients += list(inboxes)
# if post privacy is direct, we just have direct recipients,
# which is already set. hurray
return recipients
def handle_review(user, book, name, content, rating): def handle_review(user, book, name, content, rating):
''' post a review ''' ''' post a review '''
review_uuid = uuid4() review_uuid = uuid4()
@ -189,7 +136,6 @@ def handle_review(user, book, name, content, rating):
'cc': ['https://www.w3.org/ns/activitystreams#Public'], 'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': obj, 'object': obj,
} }
models.Review( models.Review(
@ -205,37 +151,3 @@ def handle_review(user, book, name, content, rating):
).save() ).save()
broadcast(user, activity, recipients) broadcast(user, activity, recipients)
def broadcast(sender, action, recipients):
''' send out an event to all followers '''
for recipient in recipients:
sign_and_send(sender, action, recipient)
def sign_and_send(sender, action, destination):
''' crpyto whatever and http junk '''
inbox_fragment = sender.inbox.replace('https://%s' % 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.localname
signature += 'headers="(request-target) host date",'
signature += 'signature="%s"' % b64encode(signed_message)
response = requests.post(
destination,
data=json.dumps(action),
headers={
'Date': now,
'Signature': signature,
'Host': DOMAIN,
},
)
if not response.ok:
response.raise_for_status()
handle_response(response)

View file

@ -1,15 +1,4 @@
""" ''' fedireads settings and configuration '''
Django settings for fedireads project.
Generated by 'django-admin startproject' using Django 2.0.13.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import os import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -25,7 +14,7 @@ SECRET_KEY = '7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr'
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
# TODO: this hsould be populated at runtime at least for debug mode
DOMAIN = 'bd352ee8.ngrok.io' DOMAIN = 'bd352ee8.ngrok.io'
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
OL_URL = 'https://openlibrary.org' OL_URL = 'https://openlibrary.org'
@ -54,6 +43,7 @@ MIDDLEWARE = [
ROOT_URLCONF = 'fedireads.urls' ROOT_URLCONF = 'fedireads.urls'
# TODO: how tf do I switch to jinja2
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',

View file

@ -1,28 +1,16 @@
"""fedireads URL Configuration ''' url routing for the app and api '''
from django.conf.urls.static import static
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from fedireads import incoming, outgoing, views, settings from fedireads import incoming, outgoing, views, settings
from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
# federation endpoints # federation endpoints
path('/inbox', incoming.shared_inbox), path('inbox', incoming.shared_inbox),
path('user/<str:username>.json', incoming.get_actor), path('user/<str:username>.json', incoming.get_actor),
path('user/<str:username>/inbox', incoming.inbox), path('user/<str:username>/inbox', incoming.inbox),
path('user/<str:username>/outbox', outgoing.outbox), path('user/<str:username>/outbox', outgoing.outbox),

View file

@ -1,19 +1,19 @@
''' application views/pages ''' ''' application views/pages '''
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login, logout from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, FilteredRelation, Q from django.db.models import Avg, FilteredRelation, Q
from django.http import HttpResponseNotFound
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponseNotFound
from fedireads import models, openlibrary
from fedireads import outgoing as api
from fedireads.settings import DOMAIN
import re import re
from fedireads import models, openlibrary, outgoing as api
@login_required @login_required
def home(request): def home(request):
''' user feed ''' ''' user's homepage with activity feed '''
shelves = models.Shelf.objects.filter(user=request.user.id) shelves = models.Shelf.objects.filter(user=request.user.id)
recent_books = models.Book.objects.order_by( recent_books = models.Book.objects.order_by(
'added_date' 'added_date'
@ -22,11 +22,15 @@ def home(request):
'shelves', 'shelves',
condition=Q(shelves__user_id=request.user.id) condition=Q(shelves__user_id=request.user.id)
) )
).values('id', 'authors', 'data', 'user_shelves', 'openlibrary_key') ).values(
'id', 'authors', 'data', 'user_shelves', 'openlibrary_key'
).distinct()
following = models.User.objects.filter( following = models.User.objects.filter(
Q(followers=request.user) | Q(id=request.user.id)) Q(followers=request.user) | Q(id=request.user.id)
)
# TODO: handle post privacy
activities = models.Activity.objects.filter( activities = models.Activity.objects.filter(
user__in=following user__in=following
).order_by('-created_date')[:10] ).order_by('-created_date')[:10]
@ -135,15 +139,20 @@ def shelve(request, shelf_id, book_id):
api.handle_shelve(request.user, book, shelf) api.handle_shelve(request.user, book, shelf)
return redirect('/') return redirect('/')
@csrf_exempt @csrf_exempt
@login_required @login_required
def review(request): def review(request):
''' create a book review note ''' ''' create a book review note '''
# TODO: error handling
book_identifier = request.POST.get('book') book_identifier = request.POST.get('book')
book = openlibrary.get_or_create_book(book_identifier) book = openlibrary.get_or_create_book(book_identifier)
# TODO: validation, htmlification
name = request.POST.get('name') name = request.POST.get('name')
content = request.POST.get('content') content = request.POST.get('content')
rating = request.POST.get('rating') rating = request.POST.get('rating')
api.handle_review(request.user, book, name, content, rating) api.handle_review(request.user, book, name, content, rating)
return redirect(book_identifier) return redirect(book_identifier)
@ -153,6 +162,7 @@ def review(request):
def follow(request): def follow(request):
''' follow another user, here or abroad ''' ''' follow another user, here or abroad '''
to_follow = request.POST.get('user') to_follow = request.POST.get('user')
# should this be an actor rather than an id? idk
to_follow = models.User.objects.get(id=to_follow) to_follow = models.User.objects.get(id=to_follow)
api.handle_outgoing_follow(request.user, to_follow) api.handle_outgoing_follow(request.user, to_follow)
@ -163,6 +173,7 @@ def follow(request):
@login_required @login_required
def unfollow(request): def unfollow(request):
''' unfollow a user ''' ''' unfollow a user '''
# TODO: this is not an implementation!!
followed = request.POST.get('user') followed = request.POST.get('user')
followed = models.User.objects.get(id=followed) followed = models.User.objects.get(id=followed)
followed.followers.remove(request.user) followed.followers.remove(request.user)
@ -177,11 +188,8 @@ def search(request):
if re.match(r'\w+@\w+.\w+', query): if re.match(r'\w+@\w+.\w+', query):
results = [api.handle_account_search(query)] results = [api.handle_account_search(query)]
else: else:
# TODO: book search
results = [] results = []
return TemplateResponse(request, 'results.html', {'results': results}) return TemplateResponse(request, 'results.html', {'results': results})
def simplify_local_username(user):
''' helper for getting the short username for local users '''
return user.username.replace('@%s' % DOMAIN, '')