etesync-server/django_etebase/serializers.py
Tom Hacohen bdd787b915 Gracefully handle uploading the same item twice.
We were failing until now, but since the uid is sure to be unique,
we can just assume that if it's the same uid it's the same content.
This means we can just gracefully fail as the data is the same.

Until now, we were raising an error, but we now just do nothing
and consider it a success.

This is especially useful when a network error caused an item to
be uploaded but not updated on the client side.
2020-11-10 10:07:56 +02:00

573 lines
20 KiB
Python

# 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 <http://www.gnu.org/licenses/>.
import base64
from django.core.files.base import ContentFile
from django.core import exceptions as django_exceptions
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from rest_framework import serializers, status
from . import models
from .utils import get_user_queryset, create_user
from .exceptions import EtebaseValidationError
User = get_user_model()
def process_revisions_for_item(item, revision_data):
chunks_objs = []
chunks = revision_data.pop('chunks_relation')
revision = models.CollectionItemRevision(**revision_data, item=item)
revision.validate_unique() # Verify there aren't any validation issues
for chunk in chunks:
uid = chunk[0]
chunk_obj = models.CollectionItemChunk.objects.filter(uid=uid).first()
if len(chunk) > 1:
content = chunk[1]
# If the chunk already exists we assume it's fine. Otherwise, we upload it.
if chunk_obj is None:
chunk_obj = models.CollectionItemChunk(uid=uid, collection=item.collection)
chunk_obj.chunkFile.save('IGNORED', ContentFile(content))
chunk_obj.save()
else:
if chunk_obj is None:
raise EtebaseValidationError('chunk_no_content', 'Tried to create a new chunk without content')
chunks_objs.append(chunk_obj)
stoken = models.Stoken.objects.create()
revision.stoken = stoken
revision.save()
for chunk in chunks_objs:
models.RevisionChunkRelation.objects.create(chunk=chunk, revision=revision)
return revision
def b64encode(value):
return base64.urlsafe_b64encode(value).decode('ascii').strip('=')
def b64decode(data):
data += "=" * ((4 - len(data) % 4) % 4)
return base64.urlsafe_b64decode(data)
def b64decode_or_bytes(data):
if isinstance(data, bytes):
return data
else:
return b64decode(data)
class BinaryBase64Field(serializers.Field):
def to_representation(self, value):
return value
def to_internal_value(self, data):
return b64decode_or_bytes(data)
class CollectionEncryptionKeyField(BinaryBase64Field):
def get_attribute(self, instance):
request = self.context.get('request', None)
if request is not None:
return instance.members.get(user=request.user).encryptionKey
return None
class CollectionTypeField(BinaryBase64Field):
def get_attribute(self, instance):
request = self.context.get('request', None)
if request is not None:
collection_type = instance.members.get(user=request.user).collectionType
return collection_type and collection_type.uid
return None
class UserSlugRelatedField(serializers.SlugRelatedField):
def get_queryset(self):
view = self.context.get('view', None)
return get_user_queryset(super().get_queryset(), view)
def __init__(self, **kwargs):
super().__init__(slug_field=User.USERNAME_FIELD, **kwargs)
def to_internal_value(self, data):
return super().to_internal_value(data.lower())
class ChunksField(serializers.RelatedField):
def to_representation(self, obj):
obj = obj.chunk
if self.context.get('prefetch') == 'auto':
with open(obj.chunkFile.path, 'rb') as f:
return (obj.uid, f.read())
else:
return (obj.uid, )
def to_internal_value(self, data):
if data[0] is None or data[1] is None:
raise EtebaseValidationError('no_null', 'null is not allowed')
return (data[0], b64decode_or_bytes(data[1]))
class BetterErrorsMixin:
@property
def errors(self):
nice = []
errors = super().errors
for error_type in errors:
if error_type == 'non_field_errors':
nice.extend(
self.flatten_errors(None, errors[error_type])
)
else:
nice.extend(
self.flatten_errors(error_type, errors[error_type])
)
if nice:
return {'code': 'field_errors',
'detail': 'Field validations failed.',
'errors': nice}
return {}
def flatten_errors(self, field_name, errors):
ret = []
if isinstance(errors, dict):
for error_key in errors:
error = errors[error_key]
ret.extend(self.flatten_errors("{}.{}".format(field_name, error_key), error))
else:
for error in errors:
if hasattr(error, 'detail'):
message = error.detail[0]
elif hasattr(error, 'message'):
message = error.message
else:
message = str(error)
ret.append({
'field': field_name,
'code': error.code,
'detail': message,
})
return ret
def transform_validation_error(self, prefix, err):
if hasattr(err, 'error_dict'):
errors = self.flatten_errors(prefix, err.error_dict)
elif not hasattr(err, 'message'):
errors = self.flatten_errors(prefix, err.error_list)
else:
raise EtebaseValidationError(err.code, err.message)
raise serializers.ValidationError({
'code': 'field_errors',
'detail': 'Field validations failed.',
'errors': errors,
})
class CollectionItemChunkSerializer(BetterErrorsMixin, serializers.ModelSerializer):
class Meta:
model = models.CollectionItemChunk
fields = ('uid', 'chunkFile')
class CollectionItemRevisionSerializer(BetterErrorsMixin, serializers.ModelSerializer):
chunks = ChunksField(
source='chunks_relation',
queryset=models.RevisionChunkRelation.objects.all(),
style={'base_template': 'input.html'},
many=True
)
meta = BinaryBase64Field()
class Meta:
model = models.CollectionItemRevision
fields = ('chunks', 'meta', 'uid', 'deleted')
extra_kwargs = {
'uid': {'validators': []}, # We deal with it in the serializers
}
class CollectionItemSerializer(BetterErrorsMixin, serializers.ModelSerializer):
encryptionKey = BinaryBase64Field(required=False, default=None, allow_null=True)
etag = serializers.CharField(allow_null=True, write_only=True)
content = CollectionItemRevisionSerializer(many=False)
class Meta:
model = models.CollectionItem
fields = ('uid', 'version', 'encryptionKey', 'content', 'etag')
def create(self, validated_data):
"""Function that's called when this serializer creates an item"""
validate_etag = self.context.get('validate_etag', False)
etag = validated_data.pop('etag')
revision_data = validated_data.pop('content')
uid = validated_data.pop('uid')
Model = self.__class__.Meta.model
with transaction.atomic():
instance, created = Model.objects.get_or_create(uid=uid, defaults=validated_data)
cur_etag = instance.etag if not created else None
# If we are trying to update an up to date item, abort early and consider it a success
if cur_etag == revision_data.get('uid'):
return instance
if validate_etag and cur_etag != etag:
raise EtebaseValidationError('wrong_etag', 'Wrong etag. Expected {} got {}'.format(cur_etag, etag),
status_code=status.HTTP_409_CONFLICT)
if not created:
# We don't have to use select_for_update here because the unique constraint on current guards against
# the race condition. But it's a good idea because it'll lock and wait rather than fail.
current_revision = instance.revisions.filter(current=True).select_for_update().first()
current_revision.current = None
current_revision.save()
try:
process_revisions_for_item(instance, revision_data)
except django_exceptions.ValidationError as e:
self.transform_validation_error("content", e)
return instance
def update(self, instance, validated_data):
# We never update, we always update in the create method
raise NotImplementedError()
class CollectionItemDepSerializer(BetterErrorsMixin, serializers.ModelSerializer):
etag = serializers.CharField()
class Meta:
model = models.CollectionItem
fields = ('uid', 'etag')
def validate(self, data):
item = self.__class__.Meta.model.objects.get(uid=data['uid'])
etag = data['etag']
if item.etag != etag:
raise EtebaseValidationError('wrong_etag', 'Wrong etag. Expected {} got {}'.format(item.etag, etag),
status_code=status.HTTP_409_CONFLICT)
return data
class CollectionItemBulkGetSerializer(BetterErrorsMixin, serializers.ModelSerializer):
etag = serializers.CharField(required=False)
class Meta:
model = models.CollectionItem
fields = ('uid', 'etag')
class CollectionListMultiSerializer(BetterErrorsMixin, serializers.Serializer):
collectionTypes = serializers.ListField(
child=BinaryBase64Field()
)
class CollectionSerializer(BetterErrorsMixin, serializers.ModelSerializer):
collectionKey = CollectionEncryptionKeyField()
collectionType = CollectionTypeField()
accessLevel = serializers.SerializerMethodField('get_access_level_from_context')
stoken = serializers.CharField(read_only=True)
item = CollectionItemSerializer(many=False, source='main_item')
class Meta:
model = models.Collection
fields = ('item', 'accessLevel', 'collectionKey', 'collectionType', 'stoken')
def get_access_level_from_context(self, obj):
request = self.context.get('request', None)
if request is not None:
return obj.members.get(user=request.user).accessLevel
return None
def create(self, validated_data):
"""Function that's called when this serializer creates an item"""
collection_key = validated_data.pop('collectionKey')
collection_type = validated_data.pop('collectionType')
user = validated_data.get('owner')
main_item_data = validated_data.pop('main_item')
etag = main_item_data.pop('etag')
revision_data = main_item_data.pop('content')
instance = self.__class__.Meta.model(**validated_data)
with transaction.atomic():
_ = self.__class__.Meta.model.objects.select_for_update().filter(owner=user)
if etag is not None:
raise EtebaseValidationError('bad_etag', 'etag is not null')
instance.save()
main_item = models.CollectionItem.objects.create(**main_item_data, collection=instance)
instance.main_item = main_item
instance.full_clean()
instance.save()
process_revisions_for_item(main_item, revision_data)
collection_type_obj, _ = models.CollectionType.objects.get_or_create(uid=collection_type, owner=user)
models.CollectionMember(collection=instance,
stoken=models.Stoken.objects.create(),
user=user,
accessLevel=models.AccessLevels.ADMIN,
encryptionKey=collection_key,
collectionType=collection_type_obj,
).save()
return instance
def update(self, instance, validated_data):
raise NotImplementedError()
class CollectionMemberSerializer(BetterErrorsMixin, serializers.ModelSerializer):
username = UserSlugRelatedField(
source='user',
read_only=True,
style={'base_template': 'input.html'},
)
class Meta:
model = models.CollectionMember
fields = ('username', 'accessLevel')
def create(self, validated_data):
raise NotImplementedError()
def update(self, instance, validated_data):
with transaction.atomic():
# We only allow updating accessLevel
access_level = validated_data.pop('accessLevel')
if instance.accessLevel != access_level:
instance.stoken = models.Stoken.objects.create()
instance.accessLevel = access_level
instance.save()
return instance
class CollectionInvitationSerializer(BetterErrorsMixin, serializers.ModelSerializer):
username = UserSlugRelatedField(
source='user',
queryset=User.objects,
style={'base_template': 'input.html'},
)
collection = serializers.CharField(source='collection.uid')
fromUsername = BinaryBase64Field(source='fromMember.user.username', read_only=True)
fromPubkey = BinaryBase64Field(source='fromMember.user.userinfo.pubkey', read_only=True)
signedEncryptionKey = BinaryBase64Field()
class Meta:
model = models.CollectionInvitation
fields = ('username', 'uid', 'collection', 'signedEncryptionKey', 'accessLevel',
'fromUsername', 'fromPubkey', 'version')
def validate_user(self, value):
request = self.context['request']
if request.user.username == value.lower():
raise EtebaseValidationError('no_self_invite', 'Inviting yourself is not allowed')
return value
def create(self, validated_data):
request = self.context['request']
collection = validated_data.pop('collection')
member = collection.members.get(user=request.user)
with transaction.atomic():
try:
return type(self).Meta.model.objects.create(**validated_data, fromMember=member)
except IntegrityError:
raise EtebaseValidationError('invitation_exists', 'Invitation already exists')
def update(self, instance, validated_data):
with transaction.atomic():
instance.accessLevel = validated_data.pop('accessLevel')
instance.signedEncryptionKey = validated_data.pop('signedEncryptionKey')
instance.save()
return instance
class InvitationAcceptSerializer(BetterErrorsMixin, serializers.Serializer):
collectionType = BinaryBase64Field()
encryptionKey = BinaryBase64Field()
def create(self, validated_data):
with transaction.atomic():
invitation = self.context['invitation']
encryption_key = validated_data.get('encryptionKey')
collection_type = validated_data.pop('collectionType')
user = invitation.user
collection_type_obj, _ = models.CollectionType.objects.get_or_create(uid=collection_type, owner=user)
member = models.CollectionMember.objects.create(
collection=invitation.collection,
stoken=models.Stoken.objects.create(),
user=user,
accessLevel=invitation.accessLevel,
encryptionKey=encryption_key,
collectionType=collection_type_obj,
)
models.CollectionMemberRemoved.objects.filter(
user=invitation.user, collection=invitation.collection).delete()
invitation.delete()
return member
def update(self, instance, validated_data):
raise NotImplementedError()
class UserSerializer(BetterErrorsMixin, serializers.ModelSerializer):
pubkey = BinaryBase64Field(source='userinfo.pubkey')
encryptedContent = BinaryBase64Field(source='userinfo.encryptedContent')
class Meta:
model = User
fields = (User.USERNAME_FIELD, User.EMAIL_FIELD, 'pubkey', 'encryptedContent')
class UserInfoPubkeySerializer(BetterErrorsMixin, serializers.ModelSerializer):
pubkey = BinaryBase64Field()
class Meta:
model = models.UserInfo
fields = ('pubkey', )
class UserSignupSerializer(BetterErrorsMixin, serializers.ModelSerializer):
class Meta:
model = User
fields = (User.USERNAME_FIELD, User.EMAIL_FIELD)
extra_kwargs = {
'username': {'validators': []}, # We specifically validate in SignupSerializer
}
class AuthenticationSignupSerializer(BetterErrorsMixin, serializers.Serializer):
"""Used both for creating new accounts and setting up existing ones for the first time.
When setting up existing ones the email is ignored."
"""
user = UserSignupSerializer(many=False)
salt = BinaryBase64Field()
loginPubkey = BinaryBase64Field()
pubkey = BinaryBase64Field()
encryptedContent = BinaryBase64Field()
def create(self, validated_data):
"""Function that's called when this serializer creates an item"""
user_data = validated_data.pop('user')
with transaction.atomic():
try:
view = self.context.get('view', None)
user_queryset = get_user_queryset(User.objects.all(), view)
instance = user_queryset.get(**{User.USERNAME_FIELD: user_data['username'].lower()})
except User.DoesNotExist:
# Create the user and save the casing the user chose as the first name
try:
instance = create_user(**user_data, password=None, first_name=user_data['username'], view=view)
instance.clean_fields()
except EtebaseValidationError as e:
raise e
except django_exceptions.ValidationError as e:
self.transform_validation_error("user", e)
except Exception as e:
raise EtebaseValidationError('generic', str(e))
if hasattr(instance, 'userinfo'):
raise EtebaseValidationError('user_exists', 'User already exists', status_code=status.HTTP_409_CONFLICT)
models.UserInfo.objects.create(**validated_data, owner=instance)
return instance
def update(self, instance, validated_data):
raise NotImplementedError()
class AuthenticationLoginChallengeSerializer(BetterErrorsMixin, serializers.Serializer):
username = serializers.CharField(required=True)
def create(self, validated_data):
raise NotImplementedError()
def update(self, instance, validated_data):
raise NotImplementedError()
class AuthenticationLoginSerializer(BetterErrorsMixin, serializers.Serializer):
response = BinaryBase64Field()
signature = BinaryBase64Field()
def create(self, validated_data):
raise NotImplementedError()
def update(self, instance, validated_data):
raise NotImplementedError()
class AuthenticationLoginInnerSerializer(AuthenticationLoginChallengeSerializer):
challenge = BinaryBase64Field()
host = serializers.CharField()
action = serializers.CharField()
def create(self, validated_data):
raise NotImplementedError()
def update(self, instance, validated_data):
raise NotImplementedError()
class AuthenticationChangePasswordInnerSerializer(AuthenticationLoginInnerSerializer):
loginPubkey = BinaryBase64Field()
encryptedContent = BinaryBase64Field()
class Meta:
model = models.UserInfo
fields = ('loginPubkey', 'encryptedContent')
def create(self, validated_data):
raise NotImplementedError()
def update(self, instance, validated_data):
with transaction.atomic():
instance.loginPubkey = validated_data.pop('loginPubkey')
instance.encryptedContent = validated_data.pop('encryptedContent')
instance.save()
return instance