diff --git a/fedireads/activitypub_templates.py b/fedireads/activitypub_templates.py index cb799d2fb..4d1bc208b 100644 --- a/fedireads/activitypub_templates.py +++ b/fedireads/activitypub_templates.py @@ -17,7 +17,7 @@ def shelve_action(user, book, shelf): '@context': 'https://www.w3.org/ns/activitystreams', 'summary': summary, 'type': 'Add', - 'actor': user.activitypub_id, + 'actor': user.actor, 'object': { 'type': 'Document', 'name': book_title, @@ -30,6 +30,16 @@ def shelve_action(user, book, shelf): } } +def follow_request(user, follow): + ''' ask to be friends ''' + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'summary': '', + 'type': 'Follow', + 'actor': user.actor, + 'object': follow, + } + def accept_follow(activity, user): ''' say YES! to a user ''' @@ -38,7 +48,33 @@ def accept_follow(activity, user): '@context': 'https://www.w3.org/ns/activitystreams', 'id': 'https://%s/%s' % (DOMAIN, uuid), 'type': 'Accept', - 'actor': user.actor['id'], + 'actor': user.actor, 'object': activity, } + +def actor(user): + ''' format an actor object from a user ''' + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + + 'id': user.actor, + 'type': 'Person', + 'preferredUsername': user.username, + 'inbox': 'https://%s/api/%s/inbox' % (DOMAIN, user.username), + 'followers': 'https://%s/api/u/%s/followers' % \ + (DOMAIN, user.username), + 'publicKey': { + 'id': 'https://%s/api/u/%s#main-key' % (DOMAIN, user.username), + 'owner': 'https://%s/api/u/%s' % (DOMAIN, user.username), + 'publicKeyPem': user.public_key, + } + } + + +def inbox(user): + ''' describe an inbox ''' + return 'https://%s/api/%s/inbox' % (DOMAIN, user.username) diff --git a/fedireads/federation.py b/fedireads/federation.py index 782e526e8..6f1027f03 100644 --- a/fedireads/federation.py +++ b/fedireads/federation.py @@ -20,7 +20,7 @@ def webfinger(request): if not resource and not resource.startswith('acct:'): return HttpResponseBadRequest() ap_id = resource.replace('acct:', '') - user = models.User.objects.filter(activitypub_id=ap_id).first() + user = models.User.objects.filter(full_username=ap_id).first() if not user: return HttpResponseNotFound('No account found') return JsonResponse(format_webfinger(user)) @@ -29,12 +29,12 @@ def webfinger(request): def format_webfinger(user): ''' helper function to create structured webfinger json ''' return { - 'subject': 'acct:%s' % (user.activitypub_id), + 'subject': 'acct:%s' % (user.full_username), 'links': [ { 'rel': 'self', 'type': 'application/activity+json', - 'href': user.actor['id'] + 'href': user.actor } ] } @@ -44,7 +44,7 @@ def format_webfinger(user): def actor(request, username): ''' return an activitypub actor object ''' user = models.User.objects.get(username=username) - return JsonResponse(user.actor) + return JsonResponse(templates.actor(user)) @csrf_exempt @@ -69,7 +69,7 @@ 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) + user = models.User.objects.get(actor=user_ap_id) shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id']) models.ShelfBook( shelf=shelf, @@ -92,11 +92,7 @@ def handle_follow(activity): following = activity['object'].replace('https://%s/api/u/' % DOMAIN, '') following = models.User.objects.get(username=following) # figure out who they are - ap_id = activity['actor'] - try: - user = models.User.objects.get(activitypub_id=ap_id) - except models.User.DoesNotExist: - user = models.User(activitypub_id=ap_id, local=False).save() + user = get_or_create_remote_user(activity) following.followers.add(user) # accept the request return templates.accept_follow(activity, following) @@ -125,7 +121,7 @@ def broadcast_action(sender, action, recipients): action['to'] = 'https://www.w3.org/ns/activitystreams#Public' action['cc'] = [recipient] - inbox_fragment = sender.actor['inbox'].replace('https://' + DOMAIN, '') + inbox_fragment = '/api/%s/inbox' % (sender.username) now = datetime.utcnow().isoformat() message_to_sign = '''(request-target): post %s host: https://%s @@ -133,12 +129,12 @@ 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 = 'keyId="%s",' % sender.full_username signature += 'headers="(request-target) host date",' signature += 'signature="%s"' % b64encode(signed_message) response = requests.post( recipient, - body=action, + data=json.dumps(action), headers={ 'Date': now, 'Signature': signature, @@ -148,8 +144,40 @@ date: %s''' % (inbox_fragment, DOMAIN, now) if not response.ok: return response.raise_for_status() +def broadcast_follow(sender, action, destination): + ''' send a follow request ''' + inbox_fragment = '/api/%s/inbox' % (sender.username) + 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.full_username + 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() + def get_or_create_remote_user(activity): - pass + actor = activity['actor'] + try: + user = models.User.objects.get(actor=actor) + except models.User.DoesNotExist: + # TODO: how do you actually correctly learn this? + username = '%s@%s' % (actor.split('/')[-1], actor.split('/')[2]) + user = models.User.objects.create_user(username, '', '', actor=actor, local=False) + return user diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index f3caea47c..5a1eac172 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-27 02:41 +# Generated by Django 2.0.13 on 2020-01-27 03:37 from django.conf import settings import django.contrib.auth.models @@ -32,11 +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')), - ('activitypub_id', models.CharField(max_length=255)), + ('full_username', models.CharField(blank=True, max_length=255, null=True, unique=True)), ('private_key', models.TextField(blank=True, null=True)), ('public_key', models.TextField(blank=True, null=True)), ('api_key', models.CharField(blank=True, max_length=255, null=True)), - ('actor', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('actor', models.CharField(max_length=255)), ('local', models.BooleanField(default=True)), ('created_date', models.DateTimeField(auto_now_add=True)), ('updated_date', models.DateTimeField(auto_now=True)), diff --git a/fedireads/models.py b/fedireads/models.py index 9c474f9c9..7022c3181 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -10,11 +10,11 @@ import re class User(AbstractUser): ''' a user who wants to read books ''' - activitypub_id = models.CharField(max_length=255) + full_username = models.CharField(max_length=255, blank=True, null=True, unique=True) private_key = models.TextField(blank=True, null=True) public_key = models.TextField(blank=True, null=True) api_key = models.CharField(max_length=255, blank=True, null=True) - actor = JSONField(blank=True, null=True) + actor = models.CharField(max_length=255) local = models.BooleanField(default=True) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) @@ -29,27 +29,9 @@ class User(AbstractUser): self.public_key = key.publickey().export_key().decode('utf8') if self.local and not self.actor: - self.actor = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1' - ], - - 'id': 'https://%s/api/u/%s' % (DOMAIN, self.username), - 'type': 'Person', - 'preferredUsername': self.username, - 'inbox': 'https://%s/api/%s/inbox' % (DOMAIN, self.username), - 'followers': 'https://%s/api/u/%s/followers' % \ - (DOMAIN, self.username), - 'publicKey': { - '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) + self.actor = 'https://%s/api/u/%s' % (DOMAIN, self.username) + if self.local and not self.full_username: + self.full_username = '%s@%s' % (self.username, DOMAIN) super().save(*args, **kwargs) diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index e9d4b0bfa..64f0401da 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -7,7 +7,7 @@ import requests 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 - olkey = '/book/' + olkey + olkey = olkey response = requests.get(OL_URL + olkey + '.json') # get the existing entry from our db, if it exists diff --git a/fedireads/templates/user.html b/fedireads/templates/user.html index 9c4413f3f..8c8928788 100644 --- a/fedireads/templates/user.html +++ b/fedireads/templates/user.html @@ -32,7 +32,7 @@

Followers

{% for follower in user.followers.all %} - {{ follower.activitypub_id }} + {{ follower.username }} {% endfor %}
diff --git a/fedireads/views.py b/fedireads/views.py index 26fdbc1b2..e389fc138 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -7,7 +7,7 @@ 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 +from fedireads.federation import broadcast_action, broadcast_follow @login_required def home(request): @@ -77,7 +77,7 @@ def shelve(request, shelf_id, book_id): # send out the activitypub action action = templates.shelve_action(request.user, book, shelf) - recipients = [u.actor['inbox'] for u in request.user.followers.all()] + recipients = [templates.inbox(u) for u in request.user.followers.all()] broadcast_action(request.user, action, recipients) return redirect('/') @@ -87,29 +87,13 @@ def shelve(request, shelf_id, book_id): @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) - 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(), - } - } + to_follow = request.POST.get('user') + to_follow = models.User.objects.get(id=to_follow) - models.Activity( - data=activity, - user=request.user, - ) + activity = templates.follow_request(request.user, to_follow.actor) + broadcast_follow(request.user, activity, templates.inbox(to_follow)) + return redirect('/user/%s' % to_follow.username) - return redirect('/user/%s' % followed.username) @csrf_exempt diff --git a/rebuilddb.sh b/rebuilddb.sh index fa1c014f4..9792e8335 100755 --- a/rebuilddb.sh +++ b/rebuilddb.sh @@ -12,6 +12,6 @@ echo "from fedireads.models import User 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 +get_or_create_book('/book/OL13549170M') +get_or_create_book('/book/OL24738110M')" | python manage.py shell python manage.py runserver