minor code cleanup and commenting

This commit is contained in:
Mouse Reeve 2020-02-07 15:11:53 -08:00
parent ef2209e77b
commit 3998c662cc
6 changed files with 76 additions and 49 deletions

View File

@ -19,34 +19,46 @@ def get_or_create_remote_user(actor):
except models.User.DoesNotExist: except models.User.DoesNotExist:
pass pass
# get the user's info # load the user's info from the actor url
response = requests.get( response = requests.get(
actor, actor,
headers={'Accept': 'application/activity+json'} headers={'Accept': 'application/activity+json'}
) )
data = response.json() data = response.json()
# the webfinger format for the username.
# TODO: get the user's domain in a better way
username = '%s@%s' % (actor.split('/')[-1], actor.split('/')[2]) username = '%s@%s' % (actor.split('/')[-1], actor.split('/')[2])
shared_inbox = data.get('endpoints').get('sharedInbox') if \ shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None data.get('endpoints') else None
user = models.User.objects.create_user(
username, '', '', try:
name=data.get('name'), user = models.User.objects.create_user(
summary=data.get('summary'), username,
inbox=data['inbox'], '', '', # email and passwords are left blank
outbox=data['outbox'], actor=actor,
shared_inbox=shared_inbox, name=data.get('name'),
public_key=data.get('publicKey').get('publicKeyPem'), summary=data.get('summary'),
actor=actor, inbox=data['inbox'], #fail if there's no inbox
local=False outbox=data['outbox'], # fail if there's no outbox
) shared_inbox=shared_inbox,
# TODO: probably shouldn't bother to store this for remote users
public_key=data.get('publicKey').get('publicKeyPem'),
local=False
)
except KeyError:
return False
return user return user
def get_recipients(user, post_privacy, direct_recipients=None): def get_recipients(user, post_privacy, direct_recipients=None):
''' deduplicated list of recipients ''' ''' deduplicated list of recipient inboxes '''
recipients = direct_recipients or [] recipients = direct_recipients or []
if post_privacy == 'direct':
# all we care about is direct_recipients, not followers
return recipients
# load all the followers of the user who is sending the message
followers = user.followers.all() followers = user.followers.all()
if post_privacy == 'public': if post_privacy == 'public':
# post to public shared inboxes # post to public shared inboxes
@ -58,28 +70,28 @@ def get_recipients(user, post_privacy, direct_recipients=None):
# don't send it to the shared inboxes # don't send it to the shared inboxes
inboxes = set(u.inbox for u in followers) inboxes = set(u.inbox for u in followers)
recipients += list(inboxes) recipients += list(inboxes)
# if post privacy is direct, we just have direct recipients,
# which is already set. hurray
return recipients return recipients
def broadcast(sender, action, recipients): def broadcast(sender, activity, recipients):
''' send out an event ''' ''' send out an event '''
errors = [] errors = []
for recipient in recipients: for recipient in recipients:
try: try:
sign_and_send(sender, action, recipient) sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
# TODO: maybe keep track of users who cause errors
errors.append({ errors.append({
'error': e, 'error': e,
'recipient': recipient, 'recipient': recipient,
'activity': action, 'activity': activity,
}) })
return errors return errors
def sign_and_send(sender, action, destination): def sign_and_send(sender, activity, destination):
''' crpyto whatever and http junk ''' ''' crpyto whatever and http junk '''
# TODO: handle http[s] with regex
inbox_fragment = sender.inbox.replace('https://%s' % DOMAIN, '') inbox_fragment = sender.inbox.replace('https://%s' % DOMAIN, '')
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
signature_headers = [ signature_headers = [
@ -88,6 +100,8 @@ def sign_and_send(sender, action, destination):
'date: %s' % now 'date: %s' % now
] ]
message_to_sign = '\n'.join(signature_headers) message_to_sign = '\n'.join(signature_headers)
# TODO: raise an error if the user doesn't have a private key
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')))
@ -101,7 +115,7 @@ def sign_and_send(sender, action, destination):
response = requests.post( response = requests.post(
destination, destination,
data=json.dumps(action), data=json.dumps(activity),
headers={ headers={
'Date': now, 'Date': now,
'Signature': signature, 'Signature': signature,

View File

@ -16,6 +16,11 @@ from fedireads.openlibrary import get_or_create_book
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
class HttpResponseUnauthorized(HttpResponse):
''' http response for authentication failure '''
status_code = 401
def webfinger(request): def webfinger(request):
''' allow other servers to ask about a user ''' ''' allow other servers to ask about a user '''
resource = request.GET.get('resource') resource = request.GET.get('resource')
@ -66,7 +71,7 @@ def shared_inbox(request):
headers={'Accept': 'application/activity+json'} headers={'Accept': 'application/activity+json'}
) )
if not response.ok: if not response.ok:
response.raise_for_status() return HttpResponseUnauthorized()
actor = response.json() actor = response.json()
key = RSA.import_key(actor['publicKey']['publicKeyPem']) key = RSA.import_key(actor['publicKey']['publicKeyPem'])
@ -85,7 +90,11 @@ def shared_inbox(request):
signer = pkcs1_15.new(key) signer = pkcs1_15.new(key)
digest = SHA256.new() digest = SHA256.new()
digest.update(comparison_string.encode()) digest.update(comparison_string.encode())
signer.verify(digest, signature) try:
signer.verify(digest, signature)
except:
# TODO: what kind of error does this throw?
return HttpResponseUnauthorized()
if activity['type'] == 'Add': if activity['type'] == 'Add':
return handle_incoming_shelve(activity) return handle_incoming_shelve(activity)
@ -137,7 +146,7 @@ def get_actor(request, username):
'publicKeyPem': user.public_key, 'publicKeyPem': user.public_key,
}, },
'endpoints': { 'endpoints': {
'sharedInbox': 'https://%s/inbox' % DOMAIN, 'sharedInbox': user.shared_inbox,
} }
}) })
@ -150,43 +159,34 @@ def get_followers(request, username):
user = models.User.objects.get(localname=username) user = models.User.objects.get(localname=username)
followers = user.followers followers = user.followers
id_slug = '%s/followers' % user.actor return format_follow_info(user, request.GET.get('page'), followers)
if request.GET.get('page'):
page = request.GET.get('page')
return JsonResponse(get_follow_page(followers, id_slug, page))
follower_count = followers.count()
return JsonResponse({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': follower_count,
'first': '%s?page=1' % id_slug,
})
@csrf_exempt @csrf_exempt
def get_following(request, username): def get_following(request, username):
''' return a list of following for an actor ''' ''' return a list of following for an actor '''
# TODO: this is total deplication of get_followers, should be streamlined
if request.method != 'GET': if request.method != 'GET':
return HttpResponseBadRequest() return HttpResponseBadRequest()
user = models.User.objects.get(localname=username) user = models.User.objects.get(localname=username)
following = models.User.objects.filter(followers=user) following = models.User.objects.filter(followers=user)
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 '''
id_slug = '%s/following' % user.actor id_slug = '%s/following' % user.actor
if request.GET.get('page'): if page:
page = request.GET.get('page') return JsonResponse(get_follow_page(follow_queryset, id_slug, page))
return JsonResponse(get_follow_page(following, id_slug, page)) count = follow_queryset.count()
following_count = following.count()
return JsonResponse({ return JsonResponse({
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug, 'id': id_slug,
'type': 'OrderedCollection', 'type': 'OrderedCollection',
'totalItems': following_count, 'totalItems': count,
'first': '%s?page=1' % id_slug, 'first': '%s?page=1' % id_slug,
}) })
def get_follow_page(user_list, id_slug, page): def get_follow_page(user_list, id_slug, page):
''' format a list of followers/following ''' ''' format a list of followers/following '''
page = int(page) page = int(page)
@ -259,6 +259,7 @@ def handle_incoming_follow(activity):
activity_type='Follow', activity_type='Follow',
) )
uuid = uuid4() uuid = uuid4()
# TODO does this need to be signed?
return JsonResponse({ return JsonResponse({
'@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),
@ -277,6 +278,7 @@ def handle_incoming_create(activity):
'inReplyTo' in activity['object']: 'inReplyTo' in activity['object']:
possible_book = activity['object']['inReplyTo'] possible_book = activity['object']['inReplyTo']
try: try:
# TODO idk about this error handling, should probs be more granular
book = get_or_create_book(possible_book) book = get_or_create_book(possible_book)
models.Review( models.Review(
uuid=uuid, uuid=uuid,
@ -318,6 +320,7 @@ def handle_incoming_accept(activity):
followed=followed, followed=followed,
content=activity, content=activity,
).save() ).save()
return HttpResponse()
def handle_response(response): def handle_response(response):

View File

@ -211,6 +211,7 @@ class Book(models.Model):
openlibrary_key = models.CharField(max_length=255, unique=True) openlibrary_key = models.CharField(max_length=255, unique=True)
data = JSONField() data = JSONField()
authors = models.ManyToManyField('Author') authors = models.ManyToManyField('Author')
# TODO: also store cover thumbnail
cover = models.ImageField(upload_to='covers/', blank=True, null=True) cover = models.ImageField(upload_to='covers/', blank=True, null=True)
shelves = models.ManyToManyField( shelves = models.ManyToManyField(
'Shelf', 'Shelf',

View File

@ -23,9 +23,10 @@ def book_search(query):
}) })
return results return results
def get_or_create_book(olkey, user=None, update=False): def get_or_create_book(olkey, user=None, update=False):
''' add a book ''' ''' add a book '''
# TODO: check if this is a valid open library key, and a book # TODO: check if this is a valid open library key, and a work
olkey = olkey olkey = olkey
# get the existing entry from our db, if it exists # get the existing entry from our db, if it exists
@ -65,6 +66,7 @@ def get_or_create_book(olkey, user=None, update=False):
def get_cover(cover_id): def get_cover(cover_id):
''' ask openlibrary for the cover ''' ''' ask openlibrary for the cover '''
# TODO: get medium and small versions
image_name = '%s-M.jpg' % cover_id image_name = '%s-M.jpg' % cover_id
url = 'https://covers.openlibrary.org/b/id/%s' % image_name url = 'https://covers.openlibrary.org/b/id/%s' % image_name
response = requests.get(url) response = requests.get(url)
@ -77,13 +79,17 @@ def get_cover(cover_id):
def get_or_create_author(olkey): def get_or_create_author(olkey):
''' load that author ''' ''' load that author '''
# TODO: validate that this is an author key # TODO: validate that this is an author key
# TODO: error handling
try: try:
author = Author.objects.get(openlibrary_key=olkey) author = Author.objects.get(openlibrary_key=olkey)
except ObjectDoesNotExist: except ObjectDoesNotExist:
response = requests.get(OL_URL + olkey + '.json') pass
data = response.json()
author = Author(openlibrary_key=olkey, data=data) response = requests.get(OL_URL + olkey + '.json')
author.save() if not response.ok:
response.raise_for_status()
data = response.json()
author = Author(openlibrary_key=olkey, data=data)
author.save()
return author return author

View File

@ -65,6 +65,7 @@ def handle_outgoing_follow(user, to_follow):
errors = broadcast(user, activity, [to_follow.inbox]) errors = broadcast(user, activity, [to_follow.inbox])
for error in errors: for error in errors:
# TODO: following masto users is returning 400
raise(error['error']) raise(error['error'])
@ -166,10 +167,11 @@ def handle_review(user, book, name, content, rating):
'published': datetime.utcnow().isoformat(), 'published': datetime.utcnow().isoformat(),
'attributedTo': user.actor, 'attributedTo': user.actor,
'content': content, 'content': content,
'inReplyTo': book.openlibrary_key, 'inReplyTo': book.openlibrary_key, # TODO is this the right identifier?
'rating': rating, # fedireads-only custom field 'rating': rating, # fedireads-only custom field
'to': 'https://www.w3.org/ns/activitystreams#Public' 'to': 'https://www.w3.org/ns/activitystreams#Public'
} }
# TODO: create alt version for mastodon
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
create_uuid = uuid4() create_uuid = uuid4()
activity = { activity = {

View File

@ -16,6 +16,7 @@ urlpatterns = [
re_path(r'^user/(?P<username>\w+)/outbox/?$', outgoing.outbox), re_path(r'^user/(?P<username>\w+)/outbox/?$', outgoing.outbox),
re_path(r'^user/(?P<username>\w+)/followers/?$', incoming.get_followers), re_path(r'^user/(?P<username>\w+)/followers/?$', incoming.get_followers),
re_path(r'^user/(?P<username>\w+)/following/?$', incoming.get_following), re_path(r'^user/(?P<username>\w+)/following/?$', incoming.get_following),
# TODO: shelves need pages in the UI and for their activitypub Collection
re_path(r'^.well-known/webfinger/?$', incoming.webfinger), re_path(r'^.well-known/webfinger/?$', incoming.webfinger),
# TODO: re_path(r'^.well-known/host-meta/?$', incoming.host_meta), # TODO: re_path(r'^.well-known/host-meta/?$', incoming.host_meta),