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)