From 91cd5c57b38821f092ac67b013a9cc45a99a721d Mon Sep 17 00:00:00 2001
From: jedi <git@m.j3d1.de>
Date: Thu, 22 Jun 2023 02:14:52 +0200
Subject: [PATCH] add /friends endpoint

---
 backend/.coveragerc                       |  4 +-
 backend/authentication/models.py          |  4 ++
 backend/authentication/signature_auth.py  | 24 +++++++-
 backend/authentication/tests/helpers.py   |  4 +-
 backend/authentication/tests/test_auth.py | 46 +++++++++++++-
 backend/backend/settings.py               |  6 +-
 backend/backend/urls.py                   |  1 +
 backend/configure.py                      | 67 +++++++++++++++++---
 backend/toolshed/__init__.py              |  0
 backend/toolshed/api/__init__.py          |  0
 backend/toolshed/api/friend.py            | 24 ++++++++
 backend/toolshed/migrations/__init__.py   |  0
 backend/toolshed/serializers.py           | 13 ++++
 backend/toolshed/tests/__init__.py        |  0
 backend/toolshed/tests/test_friend.py     | 75 +++++++++++++++++++++++
 15 files changed, 251 insertions(+), 17 deletions(-)
 create mode 100644 backend/toolshed/__init__.py
 create mode 100644 backend/toolshed/api/__init__.py
 create mode 100644 backend/toolshed/api/friend.py
 create mode 100644 backend/toolshed/migrations/__init__.py
 create mode 100644 backend/toolshed/serializers.py
 create mode 100644 backend/toolshed/tests/__init__.py
 create mode 100644 backend/toolshed/tests/test_friend.py

diff --git a/backend/.coveragerc b/backend/.coveragerc
index 9cd1bb2..eae5531 100644
--- a/backend/.coveragerc
+++ b/backend/.coveragerc
@@ -7,8 +7,10 @@ show_missing = True
 skip_covered = True
 omit =
     */tests/*
+    */migrations/*
     backend/asgi.py
     backend/wsgi.py
     backend/settings.py
     manage.py
-    configure.py
\ No newline at end of file
+    configure.py
+    testdata.py
\ No newline at end of file
diff --git a/backend/authentication/models.py b/backend/authentication/models.py
index 7ba8b20..2ee49ff 100644
--- a/backend/authentication/models.py
+++ b/backend/authentication/models.py
@@ -91,6 +91,10 @@ class ToolshedUser(AbstractUser):
     def __str__(self):
         return f"{self.username}@{self.domain}"
 
+    @property
+    def friends(self):
+        return self.public_identity.friends
+
     def sign(self, message):
         if type(message) != str:
             raise TypeError('Message must be a string')
diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py
index b46fc11..99b5060 100644
--- a/backend/authentication/signature_auth.py
+++ b/backend/authentication/signature_auth.py
@@ -1,5 +1,6 @@
 from rest_framework import authentication
-from authentication.models import ToolshedUser
+
+from authentication.models import KnownIdentity, ToolshedUser
 
 
 def split_userhandle_or_throw(userhandle):
@@ -47,6 +48,21 @@ def verify_request(request, raw_request_body):
     return username, domain, signed_data, signature_bytes_hex
 
 
+def authenticate_request_against_known_identities(request, raw_request_body):
+    try:
+        username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
+    except ValueError:
+        return None
+    try:
+        author_identity = KnownIdentity.objects.get(username=username, domain=domain)
+    except KnownIdentity.DoesNotExist:
+        return None
+    if author_identity.verify(signed_data, signature_bytes_hex):
+        return author_identity
+    else:
+        return None
+
+
 def authenticate_request_against_local_users(request, raw_request_body):
     try:
         username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
@@ -62,6 +78,12 @@ def authenticate_request_against_local_users(request, raw_request_body):
         return None
 
 
+class SignatureAuthentication(authentication.BaseAuthentication):
+    def authenticate(self, request):
+        return authenticate_request_against_known_identities(
+            request, request.body.decode('utf-8')), None
+
+
 class SignatureAuthenticationLocal(authentication.BaseAuthentication):
     def authenticate(self, request):
         return authenticate_request_against_local_users(
diff --git a/backend/authentication/tests/helpers.py b/backend/authentication/tests/helpers.py
index aa2a5d4..3eaaa55 100644
--- a/backend/authentication/tests/helpers.py
+++ b/backend/authentication/tests/helpers.py
@@ -13,10 +13,10 @@ class DummyExternalUser:
         self.username = username
         self.domain = domain
         self.__signing_key = SigningKey.generate()
-        self.public_identity, _ = KnownIdentity.objects.get_or_create(
+        self.public_identity = KnownIdentity.objects.get_or_create(
             username=username,
             domain=domain,
-            public_key=self.public_key()) if known else None
+            public_key=self.public_key())[0] if known else None
 
     def __str__(self):
         return self.username + '@' + self.domain
diff --git a/backend/authentication/tests/test_auth.py b/backend/authentication/tests/test_auth.py
index e1da5b5..2089e52 100644
--- a/backend/authentication/tests/test_auth.py
+++ b/backend/authentication/tests/test_auth.py
@@ -5,7 +5,7 @@ from nacl.encoding import HexEncoder
 from nacl.signing import SigningKey
 
 from authentication.models import ToolshedUser, KnownIdentity
-from authentication.tests import UserTestCase, SignatureAuthClient
+from authentication.tests import UserTestCase, SignatureAuthClient, DummyExternalUser
 from hostadmin.models import Domain
 
 
@@ -307,6 +307,50 @@ class UserApiTestCase(UserTestCase):
         self.assertEqual(reply.status_code, 403)
 
 
+class FriendApiTestCase(UserTestCase):
+    def setUp(self):
+        super().setUp()
+        self.local_user1.friends.add(self.local_user2.public_identity)
+        self.local_user1.friends.add(self.ext_user1.public_identity)
+        self.ext_user1.friends.add(self.local_user1.public_identity)
+        self.anonymous_client = Client(SERVER_NAME='testserver')
+        self.client = SignatureAuthClient()
+
+    def test_friend_local(self):
+        reply = self.client.get('/api/friends/', self.local_user1)
+        self.assertEqual(reply.status_code, 200)
+
+    def test_friend_external(self):
+        reply = self.client.get('/api/friends/', self.ext_user1)
+        self.assertEqual(reply.status_code, 200)
+
+    def test_friend_fail(self):
+        reply = self.anonymous_client.get('/api/friends/')
+        self.assertEqual(reply.status_code, 403)
+
+    def test_friend_fail2(self):
+        target = "/api/friends/"
+        signature = self.local_user1.sign("http://testserver2" + target)
+        header = {'HTTP_AUTHORIZATION': 'Signature ' + str(self.local_user1) + ':' + signature}
+        reply = self.anonymous_client.get(target, **header)
+        self.assertEqual(reply.status_code, 403)
+
+    def test_friend_fail3(self):
+        target = "/api/friends/"
+        unknown_user = DummyExternalUser('extuser3', 'external.org', False)
+        signature = unknown_user.sign("http://testserver" + target)
+        header = {'HTTP_AUTHORIZATION': 'Signature ' + str(unknown_user) + ':' + signature}
+        reply = self.anonymous_client.get(target, **header)
+        self.assertEqual(reply.status_code, 403)
+
+    def test_friend_fail4(self):
+        target = "/api/friends/"
+        signature = self.local_user1.sign("http://testserver" + target)
+        header = {'HTTP_AUTHORIZATION': 'Auth ' + str(self.local_user1) + ':' + signature}
+        reply = self.anonymous_client.get(target, **header)
+        self.assertEqual(reply.status_code, 403)
+
+
 class LoginApiTestCase(UserTestCase):
     user = None
     client = Client(SERVER_NAME='testserver')
diff --git a/backend/backend/settings.py b/backend/backend/settings.py
index a746d7c..8fa1adb 100644
--- a/backend/backend/settings.py
+++ b/backend/backend/settings.py
@@ -48,6 +48,7 @@ INSTALLED_APPS = [
     'drf_yasg',
     'authentication',
     'hostadmin',
+    'toolshed',
 ]
 
 REST_FRAMEWORK = {
@@ -148,11 +149,6 @@ USE_TZ = True
 STATIC_ROOT = 'staticfiles'
 STATIC_URL = '/static/'
 
-# Extra places for collectstatic to find static files.
-STATICFILES_DIRS = (
-    BASE_DIR / 'static',
-)
-
 # Default primary key field type
 # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
 
diff --git a/backend/backend/urls.py b/backend/backend/urls.py
index 9de988a..a7602fc 100644
--- a/backend/backend/urls.py
+++ b/backend/backend/urls.py
@@ -32,5 +32,6 @@ urlpatterns = [
     path('djangoadmin/', admin.site.urls),
     path('auth/', include('authentication.api')),
     path('admin/', include('hostadmin.api')),
+    path('api/', include('toolshed.api.friend')),
     path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
 ]
diff --git a/backend/configure.py b/backend/configure.py
index c962017..9f10659 100755
--- a/backend/configure.py
+++ b/backend/configure.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python3
 import os
 import sys
+from argparse import ArgumentParser
 
 import dotenv
 
@@ -75,15 +76,67 @@ def configure():
         from django.core.management import call_command
         call_command('createsuperuser')
 
-    if not os.path.exists('static'):
-        if yesno("No static directory found, do you want to create one?", default=True):
-            os.mkdir('static')
-
     call_command('collectstatic', '--no-input')
 
-    # if yesno("Do you want to load initial data?"):
-    #   call_command('loaddata', 'initial_data.json')
+
+def reset():
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
+    import django
+
+    django.setup()
+
+    try:
+        os.remove('db.sqlite3')
+    except FileNotFoundError:
+        pass
+
+    os.system("git clean -f */migrations")
+
+    from django.core.management import call_command
+
+    apps = ['authentication', 'authtoken', 'sessions', 'hostadmin', 'toolshed', 'admin']
+    for app in apps:
+        call_command('makemigrations', app)
+
+    for app in apps:
+        call_command('migrate', app)
+
+
+def testdata():
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
+    import django
+
+    django.setup()
+    if os.path.exists('testdata.py'):
+        from testdata import create_test_data
+        create_test_data()
+    else:
+        print('No testdata file found')
+        print('Please create a file named testdata.py in the shared_data directory. the function create_test_data() '
+              'will be called automatically and should create all necessary test data.')
+
+
+def main():
+    parser = ArgumentParser(description='Toolshed Server Configuration')
+    parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true')
+    parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true')
+    parser.add_argument('cmd', help='Command', default='configure', nargs='?')
+    args = parser.parse_args()
+
+    if args.yes and args.no:
+        print('Error: --yes and --no are mutually exclusive')
+        exit(1)
+
+    if args.cmd == 'configure':
+        configure()
+    elif args.cmd == 'reset':
+        reset()
+    elif args.cmd == 'testdata':
+        testdata()
+    else:
+        print('Unknown command: {}'.format(args.cmd))
+        exit(1)
 
 
 if __name__ == '__main__':
-    configure()
+    main()
diff --git a/backend/toolshed/__init__.py b/backend/toolshed/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/toolshed/api/__init__.py b/backend/toolshed/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/toolshed/api/friend.py b/backend/toolshed/api/friend.py
new file mode 100644
index 0000000..1fe36a1
--- /dev/null
+++ b/backend/toolshed/api/friend.py
@@ -0,0 +1,24 @@
+from django.urls import path
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from rest_framework.viewsets import ViewSetMixin
+
+from authentication.signature_auth import SignatureAuthentication
+from toolshed.serializers import FriendSerializer
+
+
+class Friends(APIView, ViewSetMixin):
+    authentication_classes = [SignatureAuthentication]
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request, format=None):  # /api/friends/ #
+        user = request.user
+        friends = user.friends.all()
+        serializer = FriendSerializer(friends, many=True)
+        return Response(serializer.data)
+
+
+urlpatterns = [
+    path('friends/', Friends.as_view(), name='friends'),
+]
diff --git a/backend/toolshed/migrations/__init__.py b/backend/toolshed/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py
new file mode 100644
index 0000000..19b8935
--- /dev/null
+++ b/backend/toolshed/serializers.py
@@ -0,0 +1,13 @@
+from rest_framework import serializers
+from authentication.models import KnownIdentity
+
+
+class FriendSerializer(serializers.ModelSerializer):
+    username = serializers.SerializerMethodField()
+
+    class Meta:
+        model = KnownIdentity
+        fields = ['username', 'public_key']
+
+    def get_username(self, obj):
+        return obj.username + '@' + obj.domain
diff --git a/backend/toolshed/tests/__init__.py b/backend/toolshed/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py
new file mode 100644
index 0000000..aa0f647
--- /dev/null
+++ b/backend/toolshed/tests/test_friend.py
@@ -0,0 +1,75 @@
+from authentication.tests import UserTestCase, SignatureAuthClient
+
+client = SignatureAuthClient()
+
+
+class FriendTestCase(UserTestCase):
+    def setUp(self):
+        super().setUp()
+
+    def test_friendship_iternal(self):
+        self.assertEqual(self.local_user1.friends.count(), 0)
+        self.assertEqual(self.local_user2.friends.count(), 0)
+        self.assertEqual(self.ext_user1.friends.count(), 0)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.local_user1.friends.add(self.local_user2.public_identity)
+        self.assertEqual(self.local_user1.friends.count(), 1)
+        self.assertEqual(self.local_user2.friends.count(), 1)
+        self.assertEqual(self.ext_user1.friends.count(), 0)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.assertEqual(self.local_user1.friends.first(), self.local_user2.public_identity)
+        self.assertEqual(self.local_user2.friends.first(), self.local_user1.public_identity)
+
+    def test_friendship_external(self):
+        self.assertEqual(self.local_user1.friends.count(), 0)
+        self.assertEqual(self.local_user2.friends.count(), 0)
+        self.assertEqual(self.ext_user1.friends.count(), 0)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.local_user1.friends.add(self.ext_user1.public_identity)
+        self.assertEqual(self.local_user1.friends.count(), 1)
+        self.assertEqual(self.local_user2.friends.count(), 0)
+        self.assertEqual(self.ext_user1.friends.count(), 1)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.assertEqual(self.local_user1.friends.first(), self.ext_user1.public_identity)
+        self.assertEqual(self.ext_user1.friends.first(), self.local_user1.public_identity)
+
+    def test_friend_from_external(self):
+        self.assertEqual(self.local_user1.friends.count(), 0)
+        self.assertEqual(self.local_user2.friends.count(), 0)
+        self.assertEqual(self.ext_user1.friends.count(), 0)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.ext_user1.friends.add(self.local_user1.public_identity)
+        self.assertEqual(self.local_user1.friends.count(), 1)
+        self.assertEqual(self.local_user2.friends.count(), 0)
+        self.assertEqual(self.ext_user1.friends.count(), 1)
+        self.assertEqual(self.ext_user2.friends.count(), 0)
+        self.assertEqual(self.local_user1.friends.first(), self.ext_user1.public_identity)
+        self.assertEqual(self.ext_user1.friends.first(), self.local_user1.public_identity)
+
+
+class FriendApiTestCase(UserTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.local_user1.friends.add(self.local_user2.public_identity)
+        self.local_user1.friends.add(self.ext_user1.public_identity)
+        self.ext_user1.friends.add(self.local_user1.public_identity)
+
+    def test_friend_list_internal1(self):
+        reply = client.get('/api/friends/', self.local_user1)
+        self.assertEqual(reply.status_code, 200)
+        self.assertEqual(len(reply.json()), 2)
+        self.assertEqual(reply.json()[0]['username'], str(self.local_user2))
+        self.assertEqual(reply.json()[1]['username'], str(self.ext_user1))
+
+    def test_friend_list_internal2(self):
+        reply = client.get('/api/friends/', self.local_user2)
+        self.assertEqual(reply.status_code, 200)
+        self.assertEqual(len(reply.json()), 1)
+        self.assertEqual(reply.json()[0]['username'], str(self.local_user1))
+
+    def test_friend_list_external(self):
+        reply = client.get('/api/friends/', self.ext_user1)
+        self.assertEqual(reply.status_code, 200)
+        self.assertEqual(len(reply.json()), 1)
+        self.assertEqual(reply.json()[0]['username'], str(self.local_user1))