toolshed/backend/toolshed/api/group.py
2026-09-02 21:57:33 +02:00

197 lines
10 KiB
Python

import secrets
from django.urls import path
from rest_framework import status
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.generics import get_object_or_404
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.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity, \
ToolshedUser
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
verify_incoming_group_invite
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
class Groups(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, format=None):
groups = request.user.member_of_groups.all()
return Response(GroupSerializer(groups, many=True).data)
def post(self, request, format=None):
name = request.data.get('name')
if not name:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
if Group.objects.filter(name=name, domain=request.user.domain).exists():
return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'})
group = Group.objects.create(name=name, domain=request.user.domain)
group.members.add(request.user)
return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data)
class GroupDetail(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, handle, format=None):
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(GroupSerializer(group).data)
@api_view(['DELETE'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def removeGroupMember(request, handle, identity_id, format=None):
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
member = get_object_or_404(group.members, pk=identity_id)
if group.members.count() <= 1:
return Response(status=status.HTTP_400_BAD_REQUEST,
data={'status': "cannot remove the group's last member"})
group.members.remove(member)
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def createGroupInvite(request, handle, format=None):
try:
name, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
group = get_object_or_404(Group, name=name, domain=domain)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
invitee = request.data.get('invitee')
if not invitee:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'})
try:
invitee_username, invitee_domain = split_userhandle_or_throw(invitee)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'})
if group.members.filter(username=invitee_username, domain=invitee_domain).exists():
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'})
secret = secrets.token_hex(64)
GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain,
secret=secret)
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'})
class GroupInvitesIncoming(APIView, ViewSetMixin):
def get(self, request, format=None):
raw_request = request.body.decode('utf-8')
if not (user := authenticate_request_against_local_users(request, raw_request)):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
invites = user.group_invites_incoming.all()
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
def post(self, request, format=None):
raw_request = request.body.decode('utf-8')
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain)
except ToolshedUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'})
GroupInviteIncoming.objects.create(
group_name=group_name, group_domain=group_domain,
inviter_username=inviter_username, inviter_domain=inviter_domain,
invitee_user=invitee_user, secret=request.data['secret'])
return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'})
@api_view(['DELETE'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def declineGroupInvite(request, pk, format=None):
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def recordGroupMembership(request, pk, format=None):
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
membership, _ = GroupMembership.objects.get_or_create(
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
class GroupMemberships(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthenticationLocal]
permission_classes = [IsAuthenticated]
def get(self, request, format=None):
memberships = request.user.group_memberships.all()
return Response(GroupMembershipSerializer(memberships, many=True).data)
@api_view(['POST'])
def acceptGroupInvite(request, format=None):
raw_request = request.body.decode('utf-8')
for field in ('group', 'invitee', 'invitee_key', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
group = Group.objects.get(name=group_name, domain=group_domain)
except Group.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'})
invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username,
invitee_domain=invitee_domain, secret=request.data['secret'])
if not invite.exists():
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'})
identity, _ = KnownIdentity.objects.get_or_create(
username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key'])
group.members.add(identity)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'})
urlpatterns = [
path('groups/', Groups.as_view(), name='groups'),
path('groups/<str:handle>/', GroupDetail.as_view(), name='group_detail'),
path('groups/<str:handle>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<str:handle>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
]