diff --git a/fedireads/api.py b/fedireads/api.py index bf2624bec..1c345e3dd 100644 --- a/fedireads/api.py +++ b/fedireads/api.py @@ -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) + diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 53f4114ce..46630886f 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -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) + + diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index e3bbf1faa..62e7d9248 100644 --- a/fedireads/migrations/0001_initial.py +++ b/fedireads/migrations/0001_initial.py @@ -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)), diff --git a/fedireads/models.py b/fedireads/models.py index 4a2d73d8f..1b89cff44 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -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) diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index 0e25ecfa8..04458c98a 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -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) diff --git a/fedireads/settings.py b/fedireads/settings.py index 4166726c4..698016bb2 100644 --- a/fedireads/settings.py +++ b/fedireads/settings.py @@ -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', diff --git a/fedireads/urls.py b/fedireads/urls.py index 111d513fe..5112c1755 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -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/.json', incoming.get_actor), path('user//inbox', incoming.inbox), path('user//outbox', outgoing.outbox), diff --git a/fedireads/views.py b/fedireads/views.py index 42f515f7c..1edd07a95 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -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, '')