From bb9581c1dfe742bcc28363f2f6438e9aa49a446f Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 13 Mar 2024 12:41:45 +0100 Subject: [PATCH] Implement allowedContentTypes Support to constrain files to specific content types with a "allowedContentTypes" attribute on file and image fields. Fixes: #157 --- news/157.feature | 3 + plone/namedfile/field.py | 106 ++++++++------- plone/namedfile/field.zcml | 7 +- plone/namedfile/handler.rst | 164 +++++++++++++++++++++-- plone/namedfile/interfaces.py | 22 +++ plone/namedfile/tests/test_image.py | 32 ----- plone/namedfile/tests/test_validation.py | 107 +++++++++++++++ 7 files changed, 355 insertions(+), 86 deletions(-) create mode 100644 news/157.feature create mode 100644 plone/namedfile/tests/test_validation.py diff --git a/news/157.feature b/news/157.feature new file mode 100644 index 00000000..15a58e47 --- /dev/null +++ b/news/157.feature @@ -0,0 +1,3 @@ +Support to constrain files to specific content types with a "allowedContentTypes" attribute on file and image fields. +Fixes: #157 +[thet] diff --git a/plone/namedfile/field.py b/plone/namedfile/field.py index 12dc7892..d83e3f88 100644 --- a/plone/namedfile/field.py +++ b/plone/namedfile/field.py @@ -25,9 +25,19 @@ _ = MessageFactory("plone") -@implementer(IPluggableImageFieldValidation) -@adapter(INamedImageField, Interface) -class ImageContenttypeValidator: +class InvalidFile(ValidationError): + """Exception for a invalid file.""" + + __doc__ = _("Invalid file") + + +class InvalidImageFile(ValidationError): + """Exception for a invalid image file.""" + + __doc__ = _("Invalid image file") + + +class BinaryContenttypeValidator: def __init__(self, field, value): self.field = field self.value = value @@ -35,15 +45,35 @@ def __init__(self, field, value): def __call__(self): if self.value is None: return + if not self.value.data: + # An empty file is invalid + raise self.exception(None, self.field.__name__) mimetype = get_contenttype(self.value) - if mimetype.split("/")[0] != "image": - raise InvalidImageFile(mimetype, self.field.__name__) + if self.field.allowedContentTypes: + is_allowed = False + for allowed in self.field.allowedContentTypes: + allowed_group, allowed_type = allowed.split("/") + content_group, content_type = mimetype.split("/") + if allowed_group == content_group and ( + allowed_type == content_type or allowed_type == "*" + ): + is_allowed = True + break + if not is_allowed: + raise self.exception(mimetype, self.field.__name__) -class InvalidImageFile(ValidationError): - """Exception for invalid image file""" - __doc__ = _("Invalid image file") +@implementer(IPluggableFileFieldValidation) +@adapter(INamedFileField, Interface) +class FileContenttypeValidator(BinaryContenttypeValidator): + exception = InvalidFile + + +@implementer(IPluggableImageFieldValidation) +@adapter(INamedImageField, Interface) +class ImageContenttypeValidator(BinaryContenttypeValidator): + exception = InvalidImageFile def validate_binary_field(interface, field, value): @@ -59,69 +89,55 @@ def validate_file_field(field, value): validate_binary_field(IPluggableFileFieldValidation, field, value) -@implementer(INamedFileField) -class NamedFile(Object): - """A NamedFile field""" - - _type = FileValueType - schema = INamedFile +class NamedField(Object): def __init__(self, **kw): + if "allowedContentTypes" in kw: + self.allowedContentTypes = kw.pop("allowedContentTypes") if "schema" in kw: self.schema = kw.pop("schema") super().__init__(schema=self.schema, **kw) def _validate(self, value): super()._validate(value) - validate_file_field(self, value) + self.validator(value) + + +@implementer(INamedFileField) +class NamedFile(NamedField): + """A NamedFile field""" + + _type = FileValueType + schema = INamedFile + allowedContentTypes = () + validator = validate_file_field @implementer(INamedImageField) -class NamedImage(Object): +class NamedImage(NamedField): """A NamedImage field""" _type = ImageValueType schema = INamedImage - - def __init__(self, **kw): - if "schema" in kw: - self.schema = kw.pop("schema") - super().__init__(schema=self.schema, **kw) - - def _validate(self, value): - super()._validate(value) - validate_image_field(self, value) + allowedContentTypes = ("image/*",) + validator = validate_image_field @implementer(INamedBlobFileField) -class NamedBlobFile(Object): +class NamedBlobFile(NamedField): """A NamedBlobFile field""" _type = BlobFileValueType schema = INamedBlobFile - - def __init__(self, **kw): - if "schema" in kw: - self.schema = kw.pop("schema") - super().__init__(schema=self.schema, **kw) - - def _validate(self, value): - super()._validate(value) - validate_file_field(self, value) + allowedContentTypes = () + validator = validate_file_field @implementer(INamedBlobImageField) -class NamedBlobImage(Object): +class NamedBlobImage(NamedField): """A NamedBlobImage field""" _type = BlobImageValueType schema = INamedBlobImage - - def __init__(self, **kw): - if "schema" in kw: - self.schema = kw.pop("schema") - super().__init__(schema=self.schema, **kw) - - def _validate(self, value): - super()._validate(value) - validate_image_field(self, value) + allowedContentTypes = ("image/*",) + validator = validate_image_field diff --git a/plone/namedfile/field.zcml b/plone/namedfile/field.zcml index 6a20ba28..eff96f47 100644 --- a/plone/namedfile/field.zcml +++ b/plone/namedfile/field.zcml @@ -3,9 +3,14 @@ xmlns:zcml="http://namespaces.zope.org/zcml" xmlns:browser="http://namespaces.zope.org/browser"> + + - \ No newline at end of file + diff --git a/plone/namedfile/handler.rst b/plone/namedfile/handler.rst index b1caf273..109f3a83 100644 --- a/plone/namedfile/handler.rst +++ b/plone/namedfile/handler.rst @@ -41,13 +41,23 @@ Named file :: - >>> field = NamedFile(__name__="dummy", title=u"Test", - ... description=u"Test desc", required=False, readonly=True) + >>> field = NamedFile( + ... __name__="dummy", + ... allowedContentTypes=("audio/ogg", "audio/flac"), + ... title=u"Test", + ... description=u"Test desc", + ... required=False, + ... readonly=True + ... ) >>> fieldType = IFieldNameExtractor(field)() >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) >>> element = handler.write(field, u'dummy', fieldType) #doctest: +ELLIPSIS >>> print(prettyXML(element)) + + audio/ogg + audio/flac + Test desc True False @@ -56,6 +66,10 @@ Named file >>> element = etree.XML("""\ ... + ... + ... audio/ogg + ... audio/flac + ... ... Test desc ... ... True @@ -69,6 +83,8 @@ Named file >>> reciprocal.__name__ 'dummy' + >>> reciprocal.allowedContentTypes + ('audio/ogg', 'audio/flac') >>> print(reciprocal.title) Test >>> print(reciprocal.description) @@ -84,13 +100,23 @@ Named image :: - >>> field = NamedImage(__name__="dummy", title=u"Test", - ... description=u"Test desc", required=False, readonly=True) + >>> field = NamedImage( + ... __name__="dummy", + ... allowedContentTypes=("image/png", "image/webp"), + ... title=u"Test", + ... description=u"Test desc", + ... required=False, + ... readonly=True + ... ) >>> fieldType = IFieldNameExtractor(field)() >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) >>> element = handler.write(field, u'dummy', fieldType) #doctest: +ELLIPSIS >>> print(prettyXML(element)) + + image/png + image/webp + Test desc True False @@ -99,6 +125,10 @@ Named image >>> element = etree.XML("""\ ... + ... + ... image/png + ... image/webp + ... ... Test desc ... ... True @@ -112,6 +142,8 @@ Named image >>> reciprocal.__name__ 'dummy' + >>> reciprocal.allowedContentTypes + ('image/png', 'image/webp') >>> print(reciprocal.title) Test >>> print(reciprocal.description) @@ -127,13 +159,23 @@ Named blob file :: - >>> field = NamedBlobFile(__name__="dummy", title=u"Test", - ... description=u"Test desc", required=False, readonly=True) + >>> field = NamedBlobFile( + ... __name__="dummy", + ... allowedContentTypes=("audio/ogg", "audio/flac"), + ... title=u"Test", + ... description=u"Test desc", + ... required=False, + ... readonly=True + ... ) >>> fieldType = IFieldNameExtractor(field)() >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) >>> element = handler.write(field, u'dummy', fieldType) #doctest: +ELLIPSIS >>> print(prettyXML(element)) + + audio/ogg + audio/flac + Test desc True False @@ -142,6 +184,10 @@ Named blob file >>> element = etree.XML("""\ ... + ... + ... audio/ogg + ... audio/flac + ... ... Test desc ... ... True @@ -155,6 +201,8 @@ Named blob file >>> reciprocal.__name__ 'dummy' + >>> reciprocal.allowedContentTypes + ('audio/ogg', 'audio/flac') >>> print(reciprocal.title) Test >>> print(reciprocal.description) @@ -170,13 +218,23 @@ Named blob image :: - >>> field = NamedBlobImage(__name__="dummy", title=u"Test", - ... description=u"Test desc", required=False, readonly=True) + >>> field = NamedBlobImage( + ... __name__="dummy", + ... allowedContentTypes=("image/png", "image/webp"), + ... title=u"Test", + ... description=u"Test desc", + ... required=False, + ... readonly=True + ... ) >>> fieldType = IFieldNameExtractor(field)() >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) >>> element = handler.write(field, u'dummy', fieldType) #doctest: +ELLIPSIS >>> print(prettyXML(element)) + + image/png + image/webp + Test desc True False @@ -185,6 +243,10 @@ Named blob image >>> element = etree.XML("""\ ... + ... + ... image/png + ... image/webp + ... ... Test desc ... ... True @@ -198,6 +260,8 @@ Named blob image >>> reciprocal.__name__ 'dummy' + >>> reciprocal.allowedContentTypes + ('image/png', 'image/webp') >>> print(reciprocal.title) Test >>> print(reciprocal.description) @@ -206,3 +270,87 @@ Named blob image False >>> reciprocal.readonly True + + +Test the default allowedContentTypes +------------------------------------ + +Named file:: + + >>> field = NamedFile() + >>> field.allowedContentTypes + () + >>> fieldType = IFieldNameExtractor(field)() + >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) + >>> element = handler.write(field, u'dummy', fieldType) + >>> print(prettyXML(element)) + + + >>> element__ = etree.XML("""\ + ... + ... """) + + >>> reciprocal__ = handler.read(element__) + >>> reciprocal__.allowedContentTypes + () + + +Named image:: + + >>> field = NamedImage() + >>> field.allowedContentTypes + ('image/*',) + >>> fieldType = IFieldNameExtractor(field)() + >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) + >>> element = handler.write(field, u'dummy', fieldType) + >>> print(prettyXML(element)) + + + >>> element = etree.XML("""\ + ... + ... """) + + >>> reciprocal = handler.read(element) + >>> reciprocal.allowedContentTypes + ('image/*',) + + +Named blob file:: + + >>> field = NamedBlobFile() + >>> field.allowedContentTypes + () + >>> fieldType = IFieldNameExtractor(field)() + >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) + >>> element = handler.write(field, u'dummy', fieldType) + >>> print(prettyXML(element)) + + + >>> element = etree.XML("""\ + ... + ... """) + + >>> reciprocal = handler.read(element) + >>> reciprocal.allowedContentTypes + () + + +Named blob image:: + + >>> field = NamedBlobImage() + >>> field.allowedContentTypes + ('image/*',) + >>> fieldType = IFieldNameExtractor(field)() + >>> handler = getUtility(IFieldExportImportHandler, name=fieldType) + >>> element = handler.write(field, u'dummy', fieldType) + >>> print(prettyXML(element)) + + + >>> element = etree.XML("""\ + ... + ... """) + + >>> reciprocal = handler.read(element) + >>> reciprocal.allowedContentTypes + ('image/*',) + diff --git a/plone/namedfile/interfaces.py b/plone/namedfile/interfaces.py index d7e86ae9..921085f2 100644 --- a/plone/namedfile/interfaces.py +++ b/plone/namedfile/interfaces.py @@ -97,10 +97,32 @@ class INamedField(IObject): class INamedFileField(INamedField): """Field for storing INamedFile objects.""" + allowedContentTypes = schema.Tuple( + title="Allowed Content Types", + description=( + "The content types which are allowed for this field. " + "Unset to allow any content type." + ), + value_type=schema.TextLine(), + default=(), + required=False, + ) + class INamedImageField(INamedField): """Field for storing INamedImage objects.""" + allowedContentTypes = schema.Tuple( + title="Allowed Content Types", + description=( + "The content types which are allowed for this image field. " + "The default is to allow any image/* content type." + ), + value_type=schema.TextLine(), + default=("image/*",), + required=False, + ) + class IStorage(Interface): """Store file data""" diff --git a/plone/namedfile/tests/test_image.py b/plone/namedfile/tests/test_image.py index d05a42b5..728ca9cc 100644 --- a/plone/namedfile/tests/test_image.py +++ b/plone/namedfile/tests/test_image.py @@ -110,35 +110,3 @@ def test_get_contenttype(self): ), "application/msword", ) - - -class TestValidation(unittest.TestCase): - - layer = PLONE_NAMEDFILE_INTEGRATION_TESTING - - def _makeImage(self, *args, **kw): - return NamedImage(*args, **kw) - - def testImageValidation(self): - from plone.namedfile.field import InvalidImageFile - from plone.namedfile.field import validate_image_field - from plone.namedfile.interfaces import INamedImageField - from zope.interface import implementer - - @implementer(INamedImageField) - class FakeField: - __name__ = "logo" - - # field is empty - validate_image_field(FakeField(), None) - - # field has an empty file - image = self._makeImage() - self.assertRaises(InvalidImageFile, validate_image_field, FakeField(), image) - - # field has an image file - image._setData(zptlogo) - validate_image_field(FakeField(), image) - - notimage = NamedImage(getFile("notimage.doc"), filename="notimage.doc") - self.assertRaises(InvalidImageFile, validate_image_field, FakeField(), notimage) diff --git a/plone/namedfile/tests/test_validation.py b/plone/namedfile/tests/test_validation.py new file mode 100644 index 00000000..b8906b58 --- /dev/null +++ b/plone/namedfile/tests/test_validation.py @@ -0,0 +1,107 @@ +from plone.namedfile import field +from plone.namedfile import file +from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING +from plone.namedfile.tests import getFile + +import unittest + + +class TestValidation(unittest.TestCase): + + layer = PLONE_NAMEDFILE_INTEGRATION_TESTING + + def test_validation_NamedImage_default(self): + # Testing the default allowedContentTypes + image_field = field.NamedImage( + required=False, + ) + + # field is empty, passes + field.validate_image_field(image_field, None) + + # field has an empty file, fails + named_image = file.NamedImage() + self.assertRaises(field.InvalidImageFile, image_field.validate, named_image) + + # field has an png image file, passes + named_image = file.NamedImage(getFile("image.png"), filename="image.png") + image_field.validate(named_image) + + # field has an gif image file, passes + named_image = file.NamedImage(getFile("image.gif"), filename="image.gif") + image_field.validate(named_image) + + # field has a non-image file, fails + named_image = file.NamedImage(getFile("notimage.doc"), filename="notimage.doc") + self.assertRaises(field.InvalidImageFile, image_field.validate, named_image) + + def test_validation_NamedImage_custom(self): + # Testing the default allowedContentTypes + image_field = field.NamedImage( + allowedContentTypes=("image/png", "image/webp"), + required=False, + ) + + # field is empty, passes + image_field.validate(None) + + # field has an empty file, fails + named_image = file.NamedImage() + self.assertRaises(field.InvalidImageFile, image_field.validate, named_image) + + # field has an png image file, passes + named_image = file.NamedImage(getFile("image.png"), filename="image.png") + image_field.validate(named_image) + + # field has an gif image file, fails because it's not in the allowed + # content types + named_image = file.NamedImage(getFile("image.gif"), filename="image.gif") + self.assertRaises(field.InvalidImageFile, image_field.validate, named_image) + + # field has a non-image file, fails + named_image = file.NamedImage(getFile("notimage.doc"), filename="notimage.doc") + self.assertRaises(field.InvalidImageFile, image_field.validate, named_image) + + def test_validation_NamedFile_default(self): + # Testing the default allowedContentTypes + file_field = field.NamedFile( + required=False, + ) + + # field is empty, passes + file_field.validate(None) + + # field has an empty file, fails + named_file = file.NamedFile() + self.assertRaises(field.InvalidFile, file_field.validate, named_file) + + # field has an pdf file file, passes + named_file = file.NamedFile(getFile("file.pdf"), filename="file.pdf") + file_field.validate(named_file) + + # field has an gif file, passes + named_file = file.NamedFile(getFile("image.gif"), filename="image.gif") + file_field.validate(named_file) + + def test_validation_NamedFile_custom(self): + # Testing the default allowedContentTypes + file_field = field.NamedFile( + allowedContentTypes=("application/pdf", "audio/flac"), + required=False, + ) + + # field is empty, passes + file_field.validate(None) + + # field has an empty file, fails + named_file = file.NamedFile() + self.assertRaises(field.InvalidFile, file_field.validate, named_file) + + # field has an pdf file file, passes + named_file = file.NamedFile(getFile("file.pdf"), filename="file.pdf") + file_field.validate(named_file) + + # field has an gif file, fails because it's not in the allowed + # content types + named_file = file.NamedFile(getFile("image.gif"), filename="image.gif") + self.assertRaises(field.InvalidFile, file_field.validate, named_file)