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):
''' wow, a foreigner '''
''' look up a remote user or add them '''
try:
user = models.User.objects.get(actor=actor)
except models.User.DoesNotExist:
@ -13,3 +27,56 @@ def get_or_create_remote_user(actor):
)
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, \
HttpResponseNotFound, JsonResponse
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
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):
''' allow other servers to ask about a user '''
resource = request.GET.get('resource')
@ -206,3 +208,34 @@ def handle_incoming_create(activity):
activity_type=activity['object']['type']
)
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
import django.contrib.auth.models
@ -65,6 +65,7 @@ class Migration(migrations.Migration):
('uuid', models.CharField(max_length=255, unique=True)),
('content', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)),
('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)),
('updated_date', models.DateTimeField(auto_now=True)),
('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.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, OL_URL
from Crypto.PublicKey import RSA
import re
from fedireads.settings import DOMAIN, OL_URL
class User(AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
@ -91,7 +93,10 @@ class Activity(models.Model):
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)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
@ -101,6 +106,12 @@ class ShelveActivity(Activity):
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'
shelf.fedireads_type = 'Shelve'
super().save(*args, **kwargs)
class FollowActivity(Activity):
''' record follow requests sent out '''
@ -121,12 +132,14 @@ class Review(Activity):
book = models.ForeignKey('Book', on_delete=models.PROTECT)
work = models.ForeignKey('Work', on_delete=models.PROTECT)
name = models.TextField()
# TODO: validation
rating = models.IntegerField(default=0)
review_content = models.TextField()
def save(self, *args, **kwargs):
if not self.activity_type:
self.activity_type = 'Article'
self.fedireads_type = 'Review'
super().save(*args, **kwargs)

View file

@ -1,18 +1,14 @@
''' activitystream api '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
''' handles all the activity coming out of the server '''
from datetime import datetime
from django.http import HttpResponse, JsonResponse
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
from uuid import uuid4
from fedireads import models
from fedireads.api import get_or_create_remote_user, get_recipients, \
broadcast
@csrf_exempt
def outbox(request, username):
@ -69,35 +65,6 @@ def handle_outgoing_follow(user, to_follow):
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):
''' a local user is getting a book put on their shelf '''
# update the database
@ -142,26 +109,6 @@ def handle_shelve(user, book, shelf):
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):
''' post a review '''
review_uuid = uuid4()
@ -189,7 +136,6 @@ def handle_review(user, book, name, content, rating):
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': obj,
}
models.Review(
@ -205,37 +151,3 @@ def handle_review(user, book, name, content, rating):
).save()
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 @@
"""
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/
"""
''' fedireads settings and configuration '''
import os
# 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!
DEBUG = True
# TODO: this hsould be populated at runtime at least for debug mode
DOMAIN = 'bd352ee8.ngrok.io'
ALLOWED_HOSTS = ['*']
OL_URL = 'https://openlibrary.org'
@ -54,6 +43,7 @@ MIDDLEWARE = [
ROOT_URLCONF = 'fedireads.urls'
# TODO: how tf do I switch to jinja2
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',

View file

@ -1,28 +1,16 @@
"""fedireads URL Configuration
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'))
"""
''' url routing for the app and api '''
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from fedireads import incoming, outgoing, views, settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
# federation endpoints
path('/inbox', incoming.shared_inbox),
path('inbox', incoming.shared_inbox),
path('user/<str:username>.json', incoming.get_actor),
path('user/<str:username>/inbox', incoming.inbox),
path('user/<str:username>/outbox', outgoing.outbox),

View file

@ -1,19 +1,19 @@
''' application views/pages '''
from django.contrib.auth.decorators import login_required
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.http import HttpResponseNotFound
from django.shortcuts import redirect
from django.template.response import TemplateResponse
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
from fedireads import models, openlibrary, outgoing as api
@login_required
def home(request):
''' user feed '''
''' user's homepage with activity feed '''
shelves = models.Shelf.objects.filter(user=request.user.id)
recent_books = models.Book.objects.order_by(
'added_date'
@ -22,11 +22,15 @@ def home(request):
'shelves',
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(
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(
user__in=following
).order_by('-created_date')[:10]
@ -135,15 +139,20 @@ def shelve(request, shelf_id, book_id):
api.handle_shelve(request.user, book, shelf)
return redirect('/')
@csrf_exempt
@login_required
def review(request):
''' create a book review note '''
# TODO: error handling
book_identifier = request.POST.get('book')
book = openlibrary.get_or_create_book(book_identifier)
# TODO: validation, htmlification
name = request.POST.get('name')
content = request.POST.get('content')
rating = request.POST.get('rating')
api.handle_review(request.user, book, name, content, rating)
return redirect(book_identifier)
@ -153,6 +162,7 @@ def review(request):
def follow(request):
''' follow another user, here or abroad '''
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)
api.handle_outgoing_follow(request.user, to_follow)
@ -163,6 +173,7 @@ def follow(request):
@login_required
def unfollow(request):
''' unfollow a user '''
# TODO: this is not an implementation!!
followed = request.POST.get('user')
followed = models.User.objects.get(id=followed)
followed.followers.remove(request.user)
@ -177,11 +188,8 @@ def search(request):
if re.match(r'\w+@\w+.\w+', query):
results = [api.handle_account_search(query)]
else:
# TODO: book search
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, '')