2020-01-28 19:45:27 +00:00
|
|
|
''' handles all of the activity coming in to the server '''
|
2020-01-30 04:56:18 +00:00
|
|
|
from base64 import b64decode
|
|
|
|
from Crypto.PublicKey import RSA
|
|
|
|
from Crypto.Signature import pkcs1_15
|
|
|
|
from Crypto.Hash import SHA256
|
2020-01-28 09:01:33 +00:00
|
|
|
from django.http import HttpResponse, HttpResponseBadRequest, \
|
|
|
|
HttpResponseNotFound, JsonResponse
|
|
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
import json
|
2020-01-30 04:56:18 +00:00
|
|
|
import requests
|
2020-01-28 09:01:33 +00:00
|
|
|
from uuid import uuid4
|
|
|
|
|
2020-01-28 19:45:27 +00:00
|
|
|
from fedireads import models
|
2020-02-11 23:17:21 +00:00
|
|
|
from fedireads.remote_user import get_or_create_remote_user
|
2020-01-28 19:45:27 +00:00
|
|
|
from fedireads.openlibrary import get_or_create_book
|
2020-02-15 05:45:13 +00:00
|
|
|
from fedireads.sanitize_html import InputHtmlParser
|
2020-01-28 19:45:27 +00:00
|
|
|
from fedireads.settings import DOMAIN
|
|
|
|
|
|
|
|
|
2020-01-28 09:01:33 +00:00
|
|
|
def webfinger(request):
|
|
|
|
''' allow other servers to ask about a user '''
|
|
|
|
resource = request.GET.get('resource')
|
|
|
|
if not resource and not resource.startswith('acct:'):
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
ap_id = resource.replace('acct:', '')
|
|
|
|
user = models.User.objects.filter(username=ap_id).first()
|
|
|
|
if not user:
|
|
|
|
return HttpResponseNotFound('No account found')
|
|
|
|
return JsonResponse({
|
|
|
|
'subject': 'acct:%s' % (user.username),
|
|
|
|
'links': [
|
|
|
|
{
|
|
|
|
'rel': 'self',
|
|
|
|
'type': 'application/activity+json',
|
|
|
|
'href': user.actor
|
|
|
|
}
|
|
|
|
]
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
2020-01-28 19:13:13 +00:00
|
|
|
def shared_inbox(request):
|
|
|
|
''' incoming activitypub events '''
|
2020-01-30 04:56:18 +00:00
|
|
|
# TODO: should this be functionally different from the non-shared inbox??
|
2020-01-28 19:13:13 +00:00
|
|
|
if request.method == 'GET':
|
|
|
|
return HttpResponseNotFound()
|
2020-01-28 09:01:33 +00:00
|
|
|
|
2020-01-28 19:13:13 +00:00
|
|
|
try:
|
|
|
|
activity = json.loads(request.body)
|
|
|
|
except json.decoder.JSONDecodeError:
|
2020-02-15 04:37:02 +00:00
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
try:
|
|
|
|
verify_signature(request)
|
|
|
|
except ValueError:
|
|
|
|
return HttpResponse(status=401)
|
|
|
|
|
|
|
|
response = HttpResponseNotFound()
|
|
|
|
if activity['type'] == 'Add':
|
|
|
|
response = handle_incoming_shelve(activity)
|
|
|
|
|
|
|
|
if activity['type'] == 'Follow':
|
|
|
|
response = handle_incoming_follow(activity)
|
|
|
|
|
|
|
|
if activity['type'] == 'Create':
|
|
|
|
response = handle_incoming_create(activity)
|
|
|
|
|
|
|
|
if activity['type'] == 'Accept':
|
|
|
|
response = handle_incoming_follow_accept(activity)
|
|
|
|
|
|
|
|
return response
|
2020-01-28 19:13:13 +00:00
|
|
|
|
2020-02-15 04:37:02 +00:00
|
|
|
|
|
|
|
|
|
|
|
def verify_signature(request):
|
|
|
|
''' verify rsa signature '''
|
2020-01-30 04:56:18 +00:00
|
|
|
signature_dict = {}
|
2020-02-15 04:37:02 +00:00
|
|
|
for pair in request.headers['Signature'].split(','):
|
2020-01-30 04:56:18 +00:00
|
|
|
k, v = pair.split('=', 1)
|
|
|
|
v = v.replace('"', '')
|
|
|
|
signature_dict[k] = v
|
|
|
|
|
2020-02-15 04:37:02 +00:00
|
|
|
try:
|
|
|
|
key_id = signature_dict['keyId']
|
|
|
|
headers = signature_dict['headers']
|
|
|
|
signature = b64decode(signature_dict['signature'])
|
|
|
|
except KeyError:
|
|
|
|
raise ValueError('Invalid auth header')
|
2020-01-30 04:56:18 +00:00
|
|
|
|
|
|
|
response = requests.get(
|
|
|
|
key_id,
|
|
|
|
headers={'Accept': 'application/activity+json'}
|
|
|
|
)
|
|
|
|
if not response.ok:
|
2020-02-15 04:37:02 +00:00
|
|
|
raise ValueError('Could not load public key')
|
2020-01-30 04:56:18 +00:00
|
|
|
|
|
|
|
actor = response.json()
|
|
|
|
key = RSA.import_key(actor['publicKey']['publicKeyPem'])
|
|
|
|
|
|
|
|
comparison_string = []
|
|
|
|
for signed_header_name in headers.split(' '):
|
|
|
|
if signed_header_name == '(request-target)':
|
|
|
|
comparison_string.append('(request-target): post %s' % request.path)
|
|
|
|
else:
|
|
|
|
comparison_string.append('%s: %s' % (
|
|
|
|
signed_header_name,
|
|
|
|
request.headers[signed_header_name]
|
|
|
|
))
|
|
|
|
comparison_string = '\n'.join(comparison_string)
|
|
|
|
|
|
|
|
signer = pkcs1_15.new(key)
|
|
|
|
digest = SHA256.new()
|
|
|
|
digest.update(comparison_string.encode())
|
|
|
|
|
2020-02-15 04:37:02 +00:00
|
|
|
# raises a ValueError if it fails
|
|
|
|
signer.verify(digest, signature)
|
2020-02-15 04:04:22 +00:00
|
|
|
|
2020-02-15 04:37:02 +00:00
|
|
|
return True
|
2020-01-28 09:01:33 +00:00
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
def inbox(request, username):
|
|
|
|
''' incoming activitypub events '''
|
|
|
|
# TODO: should do some kind of checking if the user accepts
|
2020-01-30 04:56:18 +00:00
|
|
|
# this action from the sender probably? idk
|
2020-01-28 09:01:33 +00:00
|
|
|
# but this will just throw an error if the user doesn't exist I guess
|
|
|
|
models.User.objects.get(localname=username)
|
|
|
|
|
2020-01-30 04:56:18 +00:00
|
|
|
return shared_inbox(request)
|
2020-01-28 09:01:33 +00:00
|
|
|
|
|
|
|
|
2020-01-28 19:13:13 +00:00
|
|
|
@csrf_exempt
|
|
|
|
def get_actor(request, username):
|
|
|
|
''' return an activitypub actor object '''
|
|
|
|
if request.method != 'GET':
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
user = models.User.objects.get(localname=username)
|
|
|
|
return JsonResponse({
|
|
|
|
'@context': [
|
|
|
|
'https://www.w3.org/ns/activitystreams',
|
|
|
|
'https://w3id.org/security/v1'
|
|
|
|
],
|
|
|
|
|
|
|
|
'id': user.actor,
|
|
|
|
'type': 'Person',
|
|
|
|
'preferredUsername': user.localname,
|
2020-01-29 19:45:19 +00:00
|
|
|
'name': user.name,
|
2020-01-28 19:13:13 +00:00
|
|
|
'inbox': user.inbox,
|
|
|
|
'followers': '%s/followers' % user.actor,
|
|
|
|
'following': '%s/following' % user.actor,
|
|
|
|
'summary': user.summary,
|
|
|
|
'publicKey': {
|
|
|
|
'id': '%s/#main-key' % user.actor,
|
|
|
|
'owner': user.actor,
|
|
|
|
'publicKeyPem': user.public_key,
|
|
|
|
},
|
|
|
|
'endpoints': {
|
2020-02-07 23:11:53 +00:00
|
|
|
'sharedInbox': user.shared_inbox,
|
2020-01-28 19:13:13 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2020-01-29 23:59:43 +00:00
|
|
|
|
2020-01-29 19:45:19 +00:00
|
|
|
@csrf_exempt
|
|
|
|
def get_followers(request, username):
|
|
|
|
''' return a list of followers for an actor '''
|
|
|
|
if request.method != 'GET':
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
user = models.User.objects.get(localname=username)
|
|
|
|
followers = user.followers
|
2020-02-07 23:11:53 +00:00
|
|
|
return format_follow_info(user, request.GET.get('page'), followers)
|
2020-01-29 19:45:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
def get_following(request, username):
|
|
|
|
''' return a list of following for an actor '''
|
|
|
|
if request.method != 'GET':
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
user = models.User.objects.get(localname=username)
|
|
|
|
following = models.User.objects.filter(followers=user)
|
2020-02-07 23:11:53 +00:00
|
|
|
return format_follow_info(user, request.GET.get('page'), following)
|
|
|
|
|
|
|
|
|
|
|
|
def format_follow_info(user, page, follow_queryset):
|
|
|
|
''' create the activitypub json for followers/following '''
|
2020-01-29 19:45:19 +00:00
|
|
|
id_slug = '%s/following' % user.actor
|
2020-02-07 23:11:53 +00:00
|
|
|
if page:
|
|
|
|
return JsonResponse(get_follow_page(follow_queryset, id_slug, page))
|
|
|
|
count = follow_queryset.count()
|
2020-01-29 19:45:19 +00:00
|
|
|
return JsonResponse({
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
|
|
'id': id_slug,
|
|
|
|
'type': 'OrderedCollection',
|
2020-02-07 23:11:53 +00:00
|
|
|
'totalItems': count,
|
2020-01-29 19:45:19 +00:00
|
|
|
'first': '%s?page=1' % id_slug,
|
|
|
|
})
|
|
|
|
|
|
|
|
def get_follow_page(user_list, id_slug, page):
|
|
|
|
''' format a list of followers/following '''
|
|
|
|
page = int(page)
|
|
|
|
page_length = 10
|
|
|
|
start = (page - 1) * page_length
|
|
|
|
end = start + page_length
|
|
|
|
follower_page = user_list.all()[start:end]
|
|
|
|
data = {
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
|
|
'id': '%s?page=%d' % (id_slug, page),
|
|
|
|
'type': 'OrderedCollectionPage',
|
|
|
|
'totalItems': user_list.count(),
|
|
|
|
'partOf': id_slug,
|
|
|
|
'orderedItems': [u.actor for u in follower_page],
|
|
|
|
}
|
|
|
|
if end <= user_list.count():
|
|
|
|
# there are still more pages
|
|
|
|
data['next'] = '%s?page=%d' % (id_slug, page + 1)
|
|
|
|
if start > 0:
|
|
|
|
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
|
|
|
|
return data
|
|
|
|
|
2020-01-28 19:13:13 +00:00
|
|
|
|
2020-01-28 09:01:33 +00:00
|
|
|
def handle_incoming_shelve(activity):
|
|
|
|
''' receiving an Add activity (to shelve a book) '''
|
|
|
|
# TODO what happens here? If it's a remote over, then I think
|
|
|
|
# I should save both the activity and the ShelfBook entry. But
|
|
|
|
# I'll do that later.
|
|
|
|
uuid = activity['id']
|
|
|
|
models.ShelveActivity.objects.get(uuid=uuid)
|
|
|
|
'''
|
|
|
|
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(actor=user_ap_id)
|
|
|
|
if not user or not user.local:
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id'])
|
|
|
|
models.ShelfBook(
|
|
|
|
shelf=shelf,
|
|
|
|
book=book,
|
|
|
|
added_by=user,
|
|
|
|
).save()
|
|
|
|
'''
|
|
|
|
return HttpResponse()
|
|
|
|
|
|
|
|
|
|
|
|
def handle_incoming_follow(activity):
|
|
|
|
'''
|
|
|
|
{
|
|
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
|
|
"id": "https://friend.camp/768222ce-a1c7-479c-a544-c93b8b67fb54",
|
|
|
|
"type": "Follow",
|
|
|
|
"actor": "https://friend.camp/users/tripofmice",
|
|
|
|
"object": "https://ff2cb3e9.ngrok.io/api/u/mouse"
|
|
|
|
}
|
|
|
|
'''
|
|
|
|
# figure out who they want to follow
|
|
|
|
to_follow = models.User.objects.get(actor=activity['object'])
|
|
|
|
# figure out who they are
|
|
|
|
user = get_or_create_remote_user(activity['actor'])
|
|
|
|
to_follow.followers.add(user)
|
|
|
|
# verify uuid and accept the request
|
|
|
|
models.FollowActivity(
|
|
|
|
uuid=activity['id'],
|
|
|
|
user=user,
|
|
|
|
followed=to_follow,
|
|
|
|
content=activity,
|
|
|
|
activity_type='Follow',
|
|
|
|
)
|
|
|
|
uuid = uuid4()
|
2020-02-07 23:11:53 +00:00
|
|
|
# TODO does this need to be signed?
|
2020-02-15 04:37:02 +00:00
|
|
|
# TODO: handle users who moderate followers instead of auto-accepting
|
2020-01-28 09:01:33 +00:00
|
|
|
return JsonResponse({
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
|
|
'id': 'https://%s/%s' % (DOMAIN, uuid),
|
|
|
|
'type': 'Accept',
|
|
|
|
'actor': user.actor,
|
|
|
|
'object': activity,
|
|
|
|
})
|
|
|
|
|
|
|
|
|
2020-02-15 04:24:35 +00:00
|
|
|
def handle_incoming_follow_accept(activity):
|
|
|
|
''' hurray, someone remote accepted a follow request '''
|
|
|
|
# figure out who they want to follow
|
|
|
|
requester = models.User.objects.get(actor=activity['object']['actor'])
|
|
|
|
# figure out who they are
|
|
|
|
accepter = get_or_create_remote_user(activity['actor'])
|
|
|
|
|
|
|
|
accepter.followers.add(requester)
|
|
|
|
return HttpResponse()
|
|
|
|
|
|
|
|
|
2020-01-28 09:01:33 +00:00
|
|
|
def handle_incoming_create(activity):
|
|
|
|
''' someone did something, good on them '''
|
|
|
|
user = get_or_create_remote_user(activity['actor'])
|
2020-02-15 05:00:11 +00:00
|
|
|
|
|
|
|
if not 'object' in activity:
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
response = HttpResponse()
|
2020-01-28 09:01:33 +00:00
|
|
|
# if it's an article and in reply to a book, we have a review
|
|
|
|
if activity['object']['type'] == 'Article' and \
|
|
|
|
'inReplyTo' in activity['object']:
|
2020-02-15 05:00:11 +00:00
|
|
|
response = create_review(user, activity)
|
|
|
|
|
2020-02-15 05:15:38 +00:00
|
|
|
models.Activity(
|
|
|
|
uuid=activity['id'],
|
|
|
|
user=user,
|
|
|
|
content=activity,
|
|
|
|
activity_type=activity['object']['type']
|
|
|
|
)
|
2020-02-15 05:00:11 +00:00
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
def create_review(user, activity):
|
|
|
|
''' a book review has been added '''
|
|
|
|
possible_book = activity['object']['inReplyTo']
|
|
|
|
try:
|
|
|
|
book = get_or_create_book(possible_book)
|
|
|
|
except ValueError:
|
|
|
|
return HttpResponseNotFound('Book \'%s\' not found' % possible_book)
|
|
|
|
|
|
|
|
content = activity['object'].get('content')
|
2020-02-15 05:45:13 +00:00
|
|
|
parser = InputHtmlParser()
|
|
|
|
parser.feed(content)
|
|
|
|
content = parser.get_output()
|
2020-02-15 05:00:11 +00:00
|
|
|
review_title = activity['object'].get('name', 'Untitled')
|
|
|
|
rating = activity['object'].get('rating', 0)
|
|
|
|
|
|
|
|
models.Review(
|
|
|
|
uuid=activity.get('id'),
|
2020-01-28 09:01:33 +00:00
|
|
|
user=user,
|
|
|
|
content=activity,
|
2020-02-15 05:00:11 +00:00
|
|
|
activity_type='Article',
|
|
|
|
book=book,
|
|
|
|
name=review_title,
|
|
|
|
rating=rating,
|
|
|
|
review_content=content,
|
|
|
|
).save()
|
2020-01-28 09:01:33 +00:00
|
|
|
return HttpResponse()
|
2020-01-28 19:45:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
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()
|
2020-02-07 23:11:53 +00:00
|
|
|
return HttpResponse()
|
2020-01-28 19:45:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|