diff --git a/fedireads/api.py b/fedireads/api.py index ac92e8e3..6d10479c 100644 --- a/fedireads/api.py +++ b/fedireads/api.py @@ -19,34 +19,46 @@ def get_or_create_remote_user(actor): except models.User.DoesNotExist: pass - # get the user's info + # load the user's info from the actor url response = requests.get( actor, headers={'Accept': 'application/activity+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]) shared_inbox = data.get('endpoints').get('sharedInbox') if \ data.get('endpoints') else None - user = models.User.objects.create_user( - username, '', '', - name=data.get('name'), - summary=data.get('summary'), - inbox=data['inbox'], - outbox=data['outbox'], - shared_inbox=shared_inbox, - public_key=data.get('publicKey').get('publicKeyPem'), - actor=actor, - local=False - ) + + try: + user = models.User.objects.create_user( + username, + '', '', # email and passwords are left blank + actor=actor, + name=data.get('name'), + summary=data.get('summary'), + inbox=data['inbox'], #fail if there's no inbox + 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 def get_recipients(user, post_privacy, direct_recipients=None): - ''' deduplicated list of recipients ''' + ''' deduplicated list of recipient inboxes ''' 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() if post_privacy == 'public': # 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 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): +def broadcast(sender, activity, recipients): ''' send out an event ''' errors = [] for recipient in recipients: try: - sign_and_send(sender, action, recipient) + sign_and_send(sender, activity, recipient) except requests.exceptions.HTTPError as e: + # TODO: maybe keep track of users who cause errors errors.append({ 'error': e, 'recipient': recipient, - 'activity': action, + 'activity': activity, }) return errors -def sign_and_send(sender, action, destination): +def sign_and_send(sender, activity, destination): ''' crpyto whatever and http junk ''' + # TODO: handle http[s] with regex inbox_fragment = sender.inbox.replace('https://%s' % DOMAIN, '') now = datetime.utcnow().isoformat() signature_headers = [ @@ -88,6 +100,8 @@ def sign_and_send(sender, action, destination): 'date: %s' % now ] 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)) 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( destination, - data=json.dumps(action), + data=json.dumps(activity), headers={ 'Date': now, 'Signature': signature, diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 402cea04..7307b66d 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -16,6 +16,11 @@ from fedireads.openlibrary import get_or_create_book from fedireads.settings import DOMAIN +class HttpResponseUnauthorized(HttpResponse): + ''' http response for authentication failure ''' + status_code = 401 + + def webfinger(request): ''' allow other servers to ask about a user ''' resource = request.GET.get('resource') @@ -66,7 +71,7 @@ def shared_inbox(request): headers={'Accept': 'application/activity+json'} ) if not response.ok: - response.raise_for_status() + return HttpResponseUnauthorized() actor = response.json() key = RSA.import_key(actor['publicKey']['publicKeyPem']) @@ -85,7 +90,11 @@ def shared_inbox(request): signer = pkcs1_15.new(key) digest = SHA256.new() 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': return handle_incoming_shelve(activity) @@ -137,7 +146,7 @@ def get_actor(request, username): 'publicKeyPem': user.public_key, }, '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) followers = user.followers - id_slug = '%s/followers' % user.actor - 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, - }) + return format_follow_info(user, request.GET.get('page'), followers) @csrf_exempt def get_following(request, username): ''' return a list of following for an actor ''' - # TODO: this is total deplication of get_followers, should be streamlined if request.method != 'GET': return HttpResponseBadRequest() user = models.User.objects.get(localname=username) 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 - if request.GET.get('page'): - page = request.GET.get('page') - return JsonResponse(get_follow_page(following, id_slug, page)) - following_count = following.count() + if page: + return JsonResponse(get_follow_page(follow_queryset, id_slug, page)) + count = follow_queryset.count() return JsonResponse({ '@context': 'https://www.w3.org/ns/activitystreams', 'id': id_slug, 'type': 'OrderedCollection', - 'totalItems': following_count, + 'totalItems': count, 'first': '%s?page=1' % id_slug, }) - def get_follow_page(user_list, id_slug, page): ''' format a list of followers/following ''' page = int(page) @@ -259,6 +259,7 @@ def handle_incoming_follow(activity): activity_type='Follow', ) uuid = uuid4() + # TODO does this need to be signed? return JsonResponse({ '@context': 'https://www.w3.org/ns/activitystreams', 'id': 'https://%s/%s' % (DOMAIN, uuid), @@ -277,6 +278,7 @@ def handle_incoming_create(activity): 'inReplyTo' in activity['object']: possible_book = activity['object']['inReplyTo'] try: + # TODO idk about this error handling, should probs be more granular book = get_or_create_book(possible_book) models.Review( uuid=uuid, @@ -318,6 +320,7 @@ def handle_incoming_accept(activity): followed=followed, content=activity, ).save() + return HttpResponse() def handle_response(response): diff --git a/fedireads/models.py b/fedireads/models.py index 3c8add40..9f359a5c 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -211,6 +211,7 @@ class Book(models.Model): openlibrary_key = models.CharField(max_length=255, unique=True) data = JSONField() authors = models.ManyToManyField('Author') + # TODO: also store cover thumbnail cover = models.ImageField(upload_to='covers/', blank=True, null=True) shelves = models.ManyToManyField( 'Shelf', diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index 413b5b45..d22046bc 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -23,9 +23,10 @@ def book_search(query): }) return results + def get_or_create_book(olkey, user=None, update=False): ''' 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 # 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): ''' ask openlibrary for the cover ''' + # TODO: get medium and small versions image_name = '%s-M.jpg' % cover_id url = 'https://covers.openlibrary.org/b/id/%s' % image_name response = requests.get(url) @@ -77,13 +79,17 @@ def get_cover(cover_id): def get_or_create_author(olkey): ''' load that author ''' # TODO: validate that this is an author key - # TODO: error handling try: author = Author.objects.get(openlibrary_key=olkey) except ObjectDoesNotExist: - response = requests.get(OL_URL + olkey + '.json') - data = response.json() - author = Author(openlibrary_key=olkey, data=data) - author.save() + pass + + response = requests.get(OL_URL + olkey + '.json') + if not response.ok: + response.raise_for_status() + + data = response.json() + author = Author(openlibrary_key=olkey, data=data) + author.save() return author diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index b0691ccd..15b72dd5 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -65,6 +65,7 @@ def handle_outgoing_follow(user, to_follow): errors = broadcast(user, activity, [to_follow.inbox]) for error in errors: + # TODO: following masto users is returning 400 raise(error['error']) @@ -166,10 +167,11 @@ def handle_review(user, book, name, content, rating): 'published': datetime.utcnow().isoformat(), 'attributedTo': user.actor, 'content': content, - 'inReplyTo': book.openlibrary_key, + 'inReplyTo': book.openlibrary_key, # TODO is this the right identifier? 'rating': rating, # fedireads-only custom field 'to': 'https://www.w3.org/ns/activitystreams#Public' } + # TODO: create alt version for mastodon recipients = get_recipients(user, 'public') create_uuid = uuid4() activity = { diff --git a/fedireads/urls.py b/fedireads/urls.py index bc5f2699..a44fefcf 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ re_path(r'^user/(?P\w+)/outbox/?$', outgoing.outbox), re_path(r'^user/(?P\w+)/followers/?$', incoming.get_followers), re_path(r'^user/(?P\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), # TODO: re_path(r'^.well-known/host-meta/?$', incoming.host_meta),