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)