Federated following
This commit is contained in:
parent
19f6ebb9a7
commit
b9d933e3b1
|
@ -17,7 +17,7 @@ def shelve_action(user, book, shelf):
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
'summary': summary,
|
'summary': summary,
|
||||||
'type': 'Add',
|
'type': 'Add',
|
||||||
'actor': user.activitypub_id,
|
'actor': user.actor,
|
||||||
'object': {
|
'object': {
|
||||||
'type': 'Document',
|
'type': 'Document',
|
||||||
'name': book_title,
|
'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):
|
def accept_follow(activity, user):
|
||||||
''' say YES! to a user '''
|
''' say YES! to a user '''
|
||||||
|
@ -38,7 +48,33 @@ def accept_follow(activity, user):
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
'id': 'https://%s/%s' % (DOMAIN, uuid),
|
'id': 'https://%s/%s' % (DOMAIN, uuid),
|
||||||
'type': 'Accept',
|
'type': 'Accept',
|
||||||
'actor': user.actor['id'],
|
'actor': user.actor,
|
||||||
'object': activity,
|
'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)
|
||||||
|
|
|
@ -20,7 +20,7 @@ def webfinger(request):
|
||||||
if not resource and not resource.startswith('acct:'):
|
if not resource and not resource.startswith('acct:'):
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
ap_id = resource.replace('acct:', '')
|
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:
|
if not user:
|
||||||
return HttpResponseNotFound('No account found')
|
return HttpResponseNotFound('No account found')
|
||||||
return JsonResponse(format_webfinger(user))
|
return JsonResponse(format_webfinger(user))
|
||||||
|
@ -29,12 +29,12 @@ def webfinger(request):
|
||||||
def format_webfinger(user):
|
def format_webfinger(user):
|
||||||
''' helper function to create structured webfinger json '''
|
''' helper function to create structured webfinger json '''
|
||||||
return {
|
return {
|
||||||
'subject': 'acct:%s' % (user.activitypub_id),
|
'subject': 'acct:%s' % (user.full_username),
|
||||||
'links': [
|
'links': [
|
||||||
{
|
{
|
||||||
'rel': 'self',
|
'rel': 'self',
|
||||||
'type': 'application/activity+json',
|
'type': 'application/activity+json',
|
||||||
'href': user.actor['id']
|
'href': user.actor
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@ def format_webfinger(user):
|
||||||
def actor(request, username):
|
def actor(request, username):
|
||||||
''' return an activitypub actor object '''
|
''' return an activitypub actor object '''
|
||||||
user = models.User.objects.get(username=username)
|
user = models.User.objects.get(username=username)
|
||||||
return JsonResponse(user.actor)
|
return JsonResponse(templates.actor(user))
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
@ -69,7 +69,7 @@ def handle_add(activity):
|
||||||
book_id = activity['object']['url']
|
book_id = activity['object']['url']
|
||||||
book = openlibrary.get_or_create_book(book_id)
|
book = openlibrary.get_or_create_book(book_id)
|
||||||
user_ap_id = activity['actor'].replace('https//:', '')
|
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'])
|
shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id'])
|
||||||
models.ShelfBook(
|
models.ShelfBook(
|
||||||
shelf=shelf,
|
shelf=shelf,
|
||||||
|
@ -92,11 +92,7 @@ def handle_follow(activity):
|
||||||
following = activity['object'].replace('https://%s/api/u/' % DOMAIN, '')
|
following = activity['object'].replace('https://%s/api/u/' % DOMAIN, '')
|
||||||
following = models.User.objects.get(username=following)
|
following = models.User.objects.get(username=following)
|
||||||
# figure out who they are
|
# figure out who they are
|
||||||
ap_id = activity['actor']
|
user = get_or_create_remote_user(activity)
|
||||||
try:
|
|
||||||
user = models.User.objects.get(activitypub_id=ap_id)
|
|
||||||
except models.User.DoesNotExist:
|
|
||||||
user = models.User(activitypub_id=ap_id, local=False).save()
|
|
||||||
following.followers.add(user)
|
following.followers.add(user)
|
||||||
# accept the request
|
# accept the request
|
||||||
return templates.accept_follow(activity, following)
|
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['to'] = 'https://www.w3.org/ns/activitystreams#Public'
|
||||||
action['cc'] = [recipient]
|
action['cc'] = [recipient]
|
||||||
|
|
||||||
inbox_fragment = sender.actor['inbox'].replace('https://' + DOMAIN, '')
|
inbox_fragment = '/api/%s/inbox' % (sender.username)
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
message_to_sign = '''(request-target): post %s
|
message_to_sign = '''(request-target): post %s
|
||||||
host: https://%s
|
host: https://%s
|
||||||
|
@ -133,12 +129,12 @@ date: %s''' % (inbox_fragment, DOMAIN, now)
|
||||||
signer = pkcs1_15.new(RSA.import_key(sender.private_key))
|
signer = pkcs1_15.new(RSA.import_key(sender.private_key))
|
||||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
|
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 += 'headers="(request-target) host date",'
|
||||||
signature += 'signature="%s"' % b64encode(signed_message)
|
signature += 'signature="%s"' % b64encode(signed_message)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
recipient,
|
recipient,
|
||||||
body=action,
|
data=json.dumps(action),
|
||||||
headers={
|
headers={
|
||||||
'Date': now,
|
'Date': now,
|
||||||
'Signature': signature,
|
'Signature': signature,
|
||||||
|
@ -148,8 +144,40 @@ date: %s''' % (inbox_fragment, DOMAIN, now)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
return response.raise_for_status()
|
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):
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
from django.conf import settings
|
||||||
import django.contrib.auth.models
|
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_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')),
|
('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')),
|
('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)),
|
('private_key', models.TextField(blank=True, null=True)),
|
||||||
('public_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)),
|
('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)),
|
('local', models.BooleanField(default=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)),
|
||||||
|
|
|
@ -10,11 +10,11 @@ import re
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
''' a user who wants to read books '''
|
''' 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)
|
private_key = models.TextField(blank=True, null=True)
|
||||||
public_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)
|
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)
|
local = models.BooleanField(default=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)
|
||||||
|
@ -29,27 +29,9 @@ class User(AbstractUser):
|
||||||
self.public_key = key.publickey().export_key().decode('utf8')
|
self.public_key = key.publickey().export_key().decode('utf8')
|
||||||
|
|
||||||
if self.local and not self.actor:
|
if self.local and not self.actor:
|
||||||
self.actor = {
|
self.actor = 'https://%s/api/u/%s' % (DOMAIN, self.username)
|
||||||
'@context': [
|
if self.local and not self.full_username:
|
||||||
'https://www.w3.org/ns/activitystreams',
|
self.full_username = '%s@%s' % (self.username, DOMAIN)
|
||||||
'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)
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import requests
|
||||||
def get_or_create_book(olkey, user=None, update=True):
|
def get_or_create_book(olkey, user=None, update=True):
|
||||||
''' add a book '''
|
''' add a book '''
|
||||||
# check if this is a valid open library key, and 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')
|
response = requests.get(OL_URL + olkey + '.json')
|
||||||
|
|
||||||
# get the existing entry from our db, if it exists
|
# get the existing entry from our db, if it exists
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h2>Followers</h2>
|
<h2>Followers</h2>
|
||||||
{% for follower in user.followers.all %}
|
{% for follower in user.followers.all %}
|
||||||
{{ follower.activitypub_id }}
|
<a href="{{ follower.actor }}">{{ follower.username }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.template.response import TemplateResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from fedireads import models
|
from fedireads import models
|
||||||
import fedireads.activitypub_templates as templates
|
import fedireads.activitypub_templates as templates
|
||||||
from fedireads.federation import broadcast_action
|
from fedireads.federation import broadcast_action, broadcast_follow
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request):
|
def home(request):
|
||||||
|
@ -77,7 +77,7 @@ def shelve(request, shelf_id, book_id):
|
||||||
|
|
||||||
# send out the activitypub action
|
# send out the activitypub action
|
||||||
action = templates.shelve_action(request.user, book, shelf)
|
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)
|
broadcast_action(request.user, action, recipients)
|
||||||
|
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
|
@ -87,29 +87,13 @@ def shelve(request, shelf_id, book_id):
|
||||||
@login_required
|
@login_required
|
||||||
def follow(request):
|
def follow(request):
|
||||||
''' follow another user, here or abroad '''
|
''' follow another user, here or abroad '''
|
||||||
followed = request.POST.get('user')
|
to_follow = request.POST.get('user')
|
||||||
followed = models.User.objects.get(id=followed)
|
to_follow = models.User.objects.get(id=to_follow)
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Activity(
|
activity = templates.follow_request(request.user, to_follow.actor)
|
||||||
data=activity,
|
broadcast_follow(request.user, activity, templates.inbox(to_follow))
|
||||||
user=request.user,
|
return redirect('/user/%s' % to_follow.username)
|
||||||
)
|
|
||||||
|
|
||||||
return redirect('/user/%s' % followed.username)
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|
|
@ -12,6 +12,6 @@ echo "from fedireads.models import User
|
||||||
User.objects.create_user('rat', 'rat@rat.com', 'ratword')
|
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
|
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
|
echo "from fedireads.openlibrary import get_or_create_book
|
||||||
get_or_create_book('OL13549170M')
|
get_or_create_book('/book/OL13549170M')
|
||||||
get_or_create_book('OL24738110M')" | python manage.py shell
|
get_or_create_book('/book/OL24738110M')" | python manage.py shell
|
||||||
python manage.py runserver
|
python manage.py runserver
|
||||||
|
|
Loading…
Reference in New Issue