From 818bb8d70f5d576f0a0d756ba8688527a2ff3dd4 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 19 Feb 2020 20:53:43 +0200 Subject: [PATCH] Create the django_etesync app. --- django_etesync/__init__.py | 1 + django_etesync/app_settings.py | 52 ++++++++ django_etesync/migrations/0001_initial.py | 67 +++++++++++ django_etesync/models.py | 79 +++++++++++- django_etesync/paginators.py | 36 ++++++ django_etesync/permissions.py | 65 ++++++++++ django_etesync/serializers.py | 88 ++++++++++++++ django_etesync/views.py | 140 +++++++++++++++++++++- 8 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 django_etesync/app_settings.py create mode 100644 django_etesync/migrations/0001_initial.py create mode 100644 django_etesync/paginators.py create mode 100644 django_etesync/permissions.py create mode 100644 django_etesync/serializers.py diff --git a/django_etesync/__init__.py b/django_etesync/__init__.py index e69de29..426fefd 100644 --- a/django_etesync/__init__.py +++ b/django_etesync/__init__.py @@ -0,0 +1 @@ +from .app_settings import app_settings diff --git a/django_etesync/app_settings.py b/django_etesync/app_settings.py new file mode 100644 index 0000000..6a04b4e --- /dev/null +++ b/django_etesync/app_settings.py @@ -0,0 +1,52 @@ +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys # noqa + + +class AppSettings: + def __init__(self, prefix): + self.prefix = prefix + + def import_from_str(self, name): + from importlib import import_module + + p, m = name.rsplit('.', 1) + + mod = import_module(p) + return getattr(mod, m) + + def _setting(self, name, dflt): + from django.conf import settings + return getattr(settings, self.prefix + name, dflt) + + @property + def API_PERMISSIONS(self): + perms = self._setting("API_PERMISSIONS", ('rest_framework.permissions.IsAuthenticated', )) + ret = [] + for perm in perms: + ret.append(self.import_from_str(perm)) + return ret + + @property + def API_AUTHENTICATORS(self): + perms = self._setting("API_AUTHENTICATORS", ('rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication')) + ret = [] + for perm in perms: + ret.append(self.import_from_str(perm)) + return ret + + +app_settings = AppSettings('ETESYNC_') diff --git a/django_etesync/migrations/0001_initial.py b/django_etesync/migrations/0001_initial.py new file mode 100644 index 0000000..6fa724b --- /dev/null +++ b/django_etesync/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 3.0.3 on 2020-02-19 15:33 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Collection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(db_index=True, max_length=44, validators=[django.core.validators.RegexValidator(message='Not a valid UID. Expected a 256bit base64url.', regex='[a-fA-F0-9\\-_=]{44}')])), + ('version', models.PositiveSmallIntegerField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('uid', 'owner')}, + }, + ), + migrations.CreateModel( + name='CollectionItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(db_index=True, max_length=44, validators=[django.core.validators.RegexValidator(message='Not a valid UID. Expected a 256bit base64url.', regex='[a-fA-F0-9\\-_=]{44}')])), + ('version', models.PositiveSmallIntegerField()), + ('encryptionKey', models.BinaryField(editable=True)), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_etesync.Collection')), + ], + options={ + 'unique_together': {('uid', 'collection')}, + }, + ), + migrations.CreateModel( + name='CollectionItemSnapshot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('current', models.BooleanField(default=True)), + ('chunkHmac', models.CharField(max_length=50)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_etesync.CollectionItem')), + ], + options={ + 'unique_together': {('item', 'current')}, + }, + ), + migrations.CreateModel( + name='CollectionItemChunk', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(db_index=True, max_length=44, validators=[django.core.validators.RegexValidator(message='Not a valid UID. Expected a 256bit base64url.', regex='[a-fA-F0-9\\-_=]{44}')])), + ('order', models.CharField(max_length=100)), + ('itemSnapshot', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_etesync.CollectionItemSnapshot')), + ], + options={ + 'ordering': ['order'], + }, + ), + ] diff --git a/django_etesync/models.py b/django_etesync/models.py index 71a8362..9e578ba 100644 --- a/django_etesync/models.py +++ b/django_etesync/models.py @@ -1,3 +1,78 @@ -from django.db import models +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . -# Create your models here. +from django.db import models +from django.conf import settings +from django.core.validators import RegexValidator +from django.utils.functional import cached_property + + +UidValidator = RegexValidator(regex=r'[a-zA-Z0-9\-_=]{44}', message='Not a valid UID. Expected a 256bit base64url.') + + +class Collection(models.Model): + uid = models.CharField(db_index=True, blank=False, null=False, + max_length=44, validators=[UidValidator]) + version = models.PositiveSmallIntegerField() + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta: + unique_together = ('uid', 'owner') + + def __str__(self): + return self.uid + + +class CollectionItem(models.Model): + uid = models.CharField(db_index=True, blank=False, null=False, + max_length=44, validators=[UidValidator]) + version = models.PositiveSmallIntegerField() + encryptionKey = models.BinaryField(editable=True, blank=False, null=False) + collection = models.ForeignKey(Collection, on_delete=models.CASCADE) + + class Meta: + unique_together = ('uid', 'collection') + + @cached_property + def content(self): + return self.snapshots.get(current=True) + + def __str__(self): + return self.uid + + +class CollectionItemSnapshot(models.Model): + item = models.ForeignKey(CollectionItem, related_name='snapshots', on_delete=models.CASCADE) + current = models.BooleanField(default=True) + chunkHmac = models.CharField(max_length=50, blank=False, null=False) + + class Meta: + unique_together = ('item', 'current') + + def __str__(self): + return "{}, current={}".format(self.item.uid, self.current) + + +class CollectionItemChunk(models.Model): + uid = models.CharField(db_index=True, blank=False, null=False, + max_length=44, validators=[UidValidator]) + itemSnapshot = models.ForeignKey(CollectionItemSnapshot, related_name='chunks', null=True, on_delete=models.SET_NULL) + order = models.CharField(max_length=100, blank=False, null=False) + + class Meta: + # unique_together = ('itemSnapshot', 'order') # Currently off because we set the item snapshot to null on deletion + ordering = ['order'] + + def __str__(self): + return self.uid diff --git a/django_etesync/paginators.py b/django_etesync/paginators.py new file mode 100644 index 0000000..6d55599 --- /dev/null +++ b/django_etesync/paginators.py @@ -0,0 +1,36 @@ +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from rest_framework import pagination +from rest_framework.response import Response + + +class LinkHeaderPagination(pagination.LimitOffsetPagination): + def get_paginated_response(self, data): + next_url = self.get_next_link() + previous_url = self.get_previous_link() + + if next_url is not None and previous_url is not None: + link = '<{next_url}>; rel="next", <{previous_url}>; rel="prev"' + elif next_url is not None: + link = '<{next_url}>; rel="next"' + elif previous_url is not None: + link = '<{previous_url}>; rel="prev"' + else: + link = '' + + link = link.format(next_url=next_url, previous_url=previous_url) + headers = {'Link': link} if link else {} + + return Response(data, headers=headers) diff --git a/django_etesync/permissions.py b/django_etesync/permissions.py new file mode 100644 index 0000000..f553930 --- /dev/null +++ b/django_etesync/permissions.py @@ -0,0 +1,65 @@ +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from rest_framework import permissions +from journal.models import Journal, JournalMember + + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + + return obj.owner == request.user + + +class IsJournalOwner(permissions.BasePermission): + """ + Custom permission to only allow owners of a journal to view it + """ + + def has_permission(self, request, view): + journal_uid = view.kwargs['journal_uid'] + try: + journal = view.get_journal_queryset().get(uid=journal_uid) + return journal.owner == request.user + except Journal.DoesNotExist: + # If the journal does not exist, we want to 404 later, not permission denied. + return True + + +class IsMemberReadOnly(permissions.BasePermission): + """ + Custom permission to make a journal read only if a read only member + """ + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + + journal_uid = view.kwargs['journal_uid'] + try: + journal = view.get_journal_queryset().get(uid=journal_uid) + member = journal.members.get(user=request.user) + return not member.readOnly + except Journal.DoesNotExist: + # If the journal does not exist, we want to 404 later, not permission denied. + return True + except JournalMember.DoesNotExist: + # Not being a member means we are the owner. + return True diff --git a/django_etesync/serializers.py b/django_etesync/serializers.py new file mode 100644 index 0000000..d4f259f --- /dev/null +++ b/django_etesync/serializers.py @@ -0,0 +1,88 @@ +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import base64 + +from django.contrib.auth import get_user_model +from rest_framework import serializers +from . import models + +User = get_user_model() + + +class BinaryBase64Field(serializers.Field): + def to_representation(self, value): + return base64.b64encode(value).decode('ascii') + + def to_internal_value(self, data): + return base64.b64decode(data) + + +class CollectionSerializer(serializers.ModelSerializer): + owner = serializers.SlugRelatedField( + slug_field=User.USERNAME_FIELD, + read_only=True + ) + encryptionKey = serializers.SerializerMethodField('get_key_from_context') + permissions = serializers.SerializerMethodField('get_permission_from_context') + ctag = serializers.SerializerMethodField('get_ctag') + + class Meta: + model = models.Collection + fields = ('uid', 'version', 'owner', 'encryptionKey', 'permissions', 'ctag') + + def get_key_from_context(self, obj): + request = self.context.get('request', None) + if request is not None: + return 'FIXME' + return None + + def get_permission_from_context(self, obj): + request = self.context.get('request', None) + if request is not None: + return 'FIXME' + return 'readOnly' + + def get_ctag(self, obj): + return 'FIXME' + + +class CollectionItemChunkSerializer(serializers.ModelSerializer): + class Meta: + model = models.CollectionItemChunk + fields = ('uid', ) + + +class CollectionItemSnapshotSerializer(serializers.ModelSerializer): + chunks = serializers.SlugRelatedField( + slug_field='uid', + queryset=models.CollectionItemChunk, + many=True + ) + + class Meta: + model = models.CollectionItemSnapshot + fields = ('chunks', 'chunkHmac') + + +class CollectionItemSerializer(serializers.ModelSerializer): + encryptionKey = BinaryBase64Field() + content = CollectionItemSnapshotSerializer( + read_only=True, + many=False + ) + + class Meta: + model = models.CollectionItem + fields = ('uid', 'version', 'encryptionKey', 'content') diff --git a/django_etesync/views.py b/django_etesync/views.py index 91ea44a..4d99dad 100644 --- a/django_etesync/views.py +++ b/django_etesync/views.py @@ -1,3 +1,139 @@ -from django.shortcuts import render +# Copyright © 2017 Tom Hacohen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . -# Create your views here. +from django.conf import settings +from django.contrib.auth import login, get_user_model +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponseBadRequest, HttpResponse, Http404 +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from rest_framework import status +from rest_framework import viewsets +from rest_framework.response import Response + +from . import app_settings, paginators +from .models import Collection, CollectionItem, CollectionItemSnapshot, CollectionItemChunk +from .serializers import ( + CollectionSerializer, + CollectionItemSerializer, + CollectionItemSnapshotSerializer, + CollectionItemChunkSerializer + ) + + +User = get_user_model() + + +class BaseViewSet(viewsets.ModelViewSet): + authentication_classes = tuple(app_settings.API_AUTHENTICATORS) + permission_classes = tuple(app_settings.API_PERMISSIONS) + + def get_serializer_class(self): + serializer_class = self.serializer_class + + if self.request.method == 'PUT': + serializer_class = getattr(self, 'serializer_update_class', serializer_class) + + return serializer_class + + def get_collection_queryset(self, queryset=Collection.objects): + return queryset.all() + + +class CollectionViewSet(BaseViewSet): + allowed_methods = ['GET', 'POST', 'DELETE'] + permission_classes = BaseViewSet.permission_classes + queryset = Collection.objects.all() + serializer_class = CollectionSerializer + lookup_field = 'uid' + + def get_queryset(self): + queryset = type(self).queryset + return self.get_collection_queryset(queryset) + + def destroy(self, request, uid=None): + # FIXME: implement + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + def create(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + try: + with transaction.atomic(): + serializer.save(owner=self.request.user) + except IntegrityError: + content = {'code': 'integrity_error'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + + return Response({}, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def list(self, request): + queryset = self.get_queryset() + + serializer = self.serializer_class(queryset, context={'request': request}, many=True) + return Response(serializer.data) + + +class CollectionItemViewSet(BaseViewSet): + allowed_methods = ['GET', 'POST'] + permission_classes = BaseViewSet.permission_classes + queryset = CollectionItem.objects.all() + serializer_class = CollectionItemSerializer + pagination_class = paginators.LinkHeaderPagination + lookup_field = 'uid' + + def get_queryset(self): + collection_uid = self.kwargs['collection_uid'] + try: + collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + except Collection.DoesNotExist: + raise Http404("Collection does not exist") + # XXX Potentially add this for performance: .prefetch_related('snapshots__chunks') + queryset = type(self).queryset.filter(collection__pk=collection.pk) + + return queryset + + def create(self, request, collection_uid=None): + collection_object = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + + many = isinstance(request.data, list) + serializer = self.serializer_class(data=request.data, many=many) + if serializer.is_valid(): + try: + serializer.save(collection=collection_object) + except IntegrityError: + content = {'code': 'integrity_error'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + + return Response({}, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, collection_uid=None, uid=None): + # FIXME: implement + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + def update(self, request, collection_uid=None, uid=None): + # FIXME: implement, or should it be implemented elsewhere? + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + def partial_update(self, request, collection_uid=None, uid=None): + # FIXME: implement, or should it be implemented elsewhere? + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)