Repository: web2py/web2py

Issue:
IS_UPLOAD_FILENAME() takes regex for extensions -- why?
So I've been spending a lot of time with `IS_UPLOAD_FILENAME` and finding out that my regex has been problematic at least once every single time.  That's when I had a realization: "why?"  Why does the `extension` parameter require regex?  Sure, there may be those rare cases where someone needs to use regex to check against an extension, but most users would probably just want to insert a list of different extensions.  I feel that introducing regex by default implies more work for website developers.

I propose adding the ability to pass a list of extensions into `IS_UPLOAD_FILENAME` (essentially what #1875 has brought up), and I would like to know if others think the same.


Discussion:
Can't you just '|'.join(your_extension_list)?
I feel that it's an extra step that web2py should handle rather than the app developer, and a frustrating one at that.  I still haven't been able to find the right regex expression to capture this particular issue, especially as the above expression allows for `potx` when checking against `pot|png`.  (It needs to be `^(pot|png)$`.)  Why have everyone re-solve the same problem, rather than just solving it once for everyone?
After some further experimentation, it seems that this problem runs deeper than expected.  I've opened up #2106 to dive into these issues.

Resolving patch:
diff --git a/gluon/__init__.py b/gluon/__init__.py
--- a/gluon/__init__.py
+++ b/gluon/__init__.py
@@ -10,7 +10,7 @@
 ========================
 """
 
-__all__ = ['A', 'B', 'BEAUTIFY', 'BODY', 'BR', 'CAT', 'CENTER', 'CLEANUP', 'CODE', 'CRYPT', 'DAL', 'DIV', 'EM', 'EMBED', 'FIELDSET', 'FORM', 'Field', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEAD', 'HR', 'HTML', 'HTTP', 'I', 'IFRAME', 'IMG', 'INPUT', 'IS_ALPHANUMERIC', 'IS_DATE', 'IS_DATETIME', 'IS_DATETIME_IN_RANGE', 'IS_DATE_IN_RANGE', 'IS_DECIMAL_IN_RANGE', 'IS_EMAIL', 'IS_LIST_OF_EMAILS', 'IS_EMPTY_OR', 'IS_EQUAL_TO', 'IS_EXPR', 'IS_FLOAT_IN_RANGE', 'IS_IMAGE', 'IS_JSON', 'IS_INT_IN_RANGE', 'IS_IN_DB', 'IS_IN_SET', 'IS_IPV4', 'IS_LENGTH', 'IS_LIST_OF', 'IS_LOWER', 'IS_MATCH', 'IS_NOT_EMPTY', 'IS_NOT_IN_DB', 'IS_NULL_OR', 'IS_SLUG', 'IS_STRONG', 'IS_TIME', 'IS_UPLOAD_FILENAME', 'IS_UPPER', 'IS_URL', 'LABEL', 'LEGEND', 'LI', 'LINK', 'LOAD', 'MARKMIN', 'MENU', 'META', 'OBJECT', 'OL', 'ON', 'OPTGROUP', 'OPTION', 'P', 'PRE', 'SCRIPT', 'SELECT', 'SPAN', 'SQLFORM', 'SQLTABLE', 'STRONG', 'STYLE', 'TABLE', 'TAG', 'TBODY', 'TD', 'TEXTAREA', 'TFOOT', 'TH', 'THEAD', 'TITLE', 'TR', 'TT', 'UL', 'URL', 'XHTML', 'XML', 'redirect', 'current', 'embed64']
+__all__ = ['A', 'B', 'BEAUTIFY', 'BODY', 'BR', 'CAT', 'CENTER', 'CLEANUP', 'CODE', 'CRYPT', 'DAL', 'DIV', 'EM', 'EMBED', 'FIELDSET', 'FORM', 'Field', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEAD', 'HR', 'HTML', 'HTTP', 'I', 'IFRAME', 'IMG', 'INPUT', 'IS_ALPHANUMERIC', 'IS_DATE', 'IS_DATETIME', 'IS_DATETIME_IN_RANGE', 'IS_DATE_IN_RANGE', 'IS_DECIMAL_IN_RANGE', 'IS_EMAIL', 'IS_LIST_OF_EMAILS', 'IS_EMPTY_OR', 'IS_EQUAL_TO', 'IS_EXPR', 'IS_FILE', 'IS_FLOAT_IN_RANGE', 'IS_IMAGE', 'IS_JSON', 'IS_INT_IN_RANGE', 'IS_IN_DB', 'IS_IN_SET', 'IS_IPV4', 'IS_LENGTH', 'IS_LIST_OF', 'IS_LOWER', 'IS_MATCH', 'IS_NOT_EMPTY', 'IS_NOT_IN_DB', 'IS_NULL_OR', 'IS_SLUG', 'IS_STRONG', 'IS_TIME', 'IS_UPLOAD_FILENAME', 'IS_UPPER', 'IS_URL', 'LABEL', 'LEGEND', 'LI', 'LINK', 'LOAD', 'MARKMIN', 'MENU', 'META', 'OBJECT', 'OL', 'ON', 'OPTGROUP', 'OPTION', 'P', 'PRE', 'SCRIPT', 'SELECT', 'SPAN', 'SQLFORM', 'SQLTABLE', 'STRONG', 'STYLE', 'TABLE', 'TAG', 'TBODY', 'TD', 'TEXTAREA', 'TFOOT', 'TH', 'THEAD', 'TITLE', 'TR', 'TT', 'UL', 'URL', 'XHTML', 'XML', 'redirect', 'current', 'embed64']
 
 #: add pydal to sys.modules
 import os
diff --git a/gluon/highlight.py b/gluon/highlight.py
--- a/gluon/highlight.py
+++ b/gluon/highlight.py
@@ -166,7 +166,7 @@ def html_tokenizer(
                         + r'from|True|False)(?![a-zA-Z0-9_])'),
              'color:#185369; font-weight: bold'),
             ('WEB2PY',
-             re.compile(r'(request|response|session|cache|redirect|local_import|HTTP|TR|XML|URL|BEAUTIFY|A|BODY|BR|B|CAT|CENTER|CODE|COL|COLGROUP|DIV|EM|EMBED|FIELDSET|LEGEND|FORM|H1|H2|H3|H4|H5|H6|IFRAME|HEAD|HR|HTML|I|IMG|INPUT|LABEL|LI|LINK|MARKMIN|MENU|META|OBJECT|OL|ON|OPTION|P|PRE|SCRIPT|SELECT|SPAN|STYLE|TABLE|THEAD|TBODY|TFOOT|TAG|TD|TEXTAREA|TH|TITLE|TT|T|UL|XHTML|IS_SLUG|IS_STRONG|IS_LOWER|IS_UPPER|IS_ALPHANUMERIC|IS_DATETIME|IS_DATETIME_IN_RANGE|IS_DATE|IS_DATE_IN_RANGE|IS_DECIMAL_IN_RANGE|IS_EMAIL|IS_EXPR|IS_FLOAT_IN_RANGE|IS_IMAGE|IS_INT_IN_RANGE|IS_IN_SET|IS_IPV4|IS_LIST_OF|IS_LENGTH|IS_MATCH|IS_EQUAL_TO|IS_EMPTY_OR|IS_NULL_OR|IS_NOT_EMPTY|IS_TIME|IS_UPLOAD_FILENAME|IS_URL|CLEANUP|CRYPT|IS_IN_DB|IS_NOT_IN_DB|DAL|Field|SQLFORM|SQLTABLE|xmlescape|embed64)(?![a-zA-Z0-9_])'
+             re.compile(r'(request|response|session|cache|redirect|local_import|HTTP|TR|XML|URL|BEAUTIFY|A|BODY|BR|B|CAT|CENTER|CODE|COL|COLGROUP|DIV|EM|EMBED|FIELDSET|LEGEND|FORM|H1|H2|H3|H4|H5|H6|IFRAME|HEAD|HR|HTML|I|IMG|INPUT|LABEL|LI|LINK|MARKMIN|MENU|META|OBJECT|OL|ON|OPTION|P|PRE|SCRIPT|SELECT|SPAN|STYLE|TABLE|THEAD|TBODY|TFOOT|TAG|TD|TEXTAREA|TH|TITLE|TT|T|UL|XHTML|IS_SLUG|IS_STRONG|IS_LOWER|IS_UPPER|IS_ALPHANUMERIC|IS_DATETIME|IS_DATETIME_IN_RANGE|IS_DATE|IS_DATE_IN_RANGE|IS_DECIMAL_IN_RANGE|IS_EMAIL|IS_EXPR|IS_FILE|IS_FLOAT_IN_RANGE|IS_IMAGE|IS_INT_IN_RANGE|IS_IN_SET|IS_IPV4|IS_LIST_OF|IS_LENGTH|IS_MATCH|IS_EQUAL_TO|IS_EMPTY_OR|IS_NULL_OR|IS_NOT_EMPTY|IS_TIME|IS_UPLOAD_FILENAME|IS_URL|CLEANUP|CRYPT|IS_IN_DB|IS_NOT_IN_DB|DAL|Field|SQLFORM|SQLTABLE|xmlescape|embed64)(?![a-zA-Z0-9_])'
                         ), 'link:%(link)s;text-decoration:None;color:#FF5C1F;'),
             ('MAGIC', re.compile(r'self|None'),
              'color:#185369; font-weight: bold'),
diff --git a/gluon/validators.py b/gluon/validators.py
--- a/gluon/validators.py
+++ b/gluon/validators.py
@@ -48,6 +48,7 @@
     'IS_LIST_OF_EMAILS',
     'IS_EMPTY_OR',
     'IS_EXPR',
+    'IS_FILE',
     'IS_FLOAT_IN_RANGE',
     'IS_IMAGE',
     'IS_IN_DB',
@@ -3339,8 +3340,100 @@ def __png(self, stream):
         return (-1, -1)
 
 
+class IS_FILE(Validator):
+    """
+    Checks if name and extension of file uploaded through file input matches
+    given criteria.
+
+    Does *not* ensure the file type in any way. Returns validation failure
+    if no data was uploaded.
+
+    Args:
+        filename: string/compiled regex or a list of strings/regex of valid filenames
+        extension: string/compiled regex or a list of strings/regex of valid extensions
+        lastdot: which dot should be used as a filename / extension separator:
+            True means last dot, eg. file.jpg.png -> file.jpg / png
+            False means first dot, eg. file.tar.gz -> file / tar.gz
+        case: 0 - keep the case, 1 - transform the string into lowercase (default),
+            2 - transform the string into uppercase
+
+    If there is no dot present, extension checks will be done against empty
+    string and filename checks against whole value.
+
+    Examples:
+        Check if file has a pdf extension (case insensitive):
+
+        INPUT(_type='file', _name='name',
+                requires=IS_FILE(extension='pdf'))
+
+        Check if file is called 'thumbnail' and has a jpg or png extension
+        (case insensitive):
+
+        INPUT(_type='file', _name='name',
+                requires=IS_FILE(filename='thumbnail',
+                extension=['jpg', 'png']))
+
+        Check if file has a tar.gz extension and name starting with backup:
+
+        INPUT(_type='file', _name='name',
+                requires=IS_FILE(filename=re.compile('backup.*'),
+                extension='tar.gz', lastdot=False))
+
+        Check if file has no extension and name matching README
+        (case sensitive):
+
+            INPUT(_type='file', _name='name',
+                requires=IS_FILE(filename='README',
+                extension='', case=0)
+
+    """
+
+    def __init__(self, filename=None, extension=None, lastdot=True, case=1,
+                 error_message='Enter valid filename'):
+        self.filename = filename
+        self.extension = extension
+        self.lastdot = lastdot
+        self.case = case
+        self.error_message = error_message
+
+    def match(self, value1, value2):
+        if isinstance(value1, (list, tuple)):
+            for v in value1:
+                if self.match(v, value2):
+                    return True
+            return False
+        elif isinstance(value1, type(regex_isint)):
+            return value1.match(value2)
+        elif isinstance(value1, str):
+            return value1 == value2
+
+    def __call__(self, value):
+        try:
+            string = value.filename
+        except:
+            return (value, translator(self.error_message))
+        if self.case == 1:
+            string = string.lower()
+        elif self.case == 2:
+            string = string.upper()
+        if self.lastdot:
+            dot = string.rfind('.')
+        else:
+            dot = string.find('.')
+        if dot == -1:
+            dot = len(string)
+        if self.filename and not self.match(self.filename, string[:dot]):
+            return (value, translator(self.error_message))
+        elif self.extension and not self.match(self.extension, string[dot + 1:]):
+            return (value, translator(self.error_message))
+        else:
+            return (value, None)
+
+
 class IS_UPLOAD_FILENAME(Validator):
     """
+    For new applications, use IS_FILE().
+
     Checks if name and extension of file uploaded through file input matches
     given criteria.
 


Test patch:
diff --git a/gluon/tests/test_validators.py b/gluon/tests/test_validators.py
--- a/gluon/tests/test_validators.py
+++ b/gluon/tests/test_validators.py
@@ -1021,6 +1021,65 @@ def __init__(self, filename, ext, width, height):
         rtn = IS_IMAGE(error_message='oops')(img)
         self.assertEqual(rtn, (img, 'oops'))
 
+    def test_IS_FILE(self):
+        import cgi
+        from io import BytesIO
+
+        def gen_fake(filename):
+            formdata_file_data = """
+---123
+Content-Disposition: form-data; name="key2"
+
+value2y
+---123
+Content-Disposition: form-data; name="file_attach"; filename="%s"
+Content-Type: text/plain
+
+this is the content of the fake file
+
+---123--
+""" % filename
+            formdata_file_environ = {
+                'CONTENT_LENGTH':   str(len(formdata_file_data)),
+                'CONTENT_TYPE':     'multipart/form-data; boundary=-123',
+                'QUERY_STRING':     'key1=value1&key2=value2x',
+                'REQUEST_METHOD':   'POST',
+            }
+            return cgi.FieldStorage(fp=BytesIO(to_bytes(formdata_file_data)), environ=formdata_file_environ)['file_attach']
+
+        fake = gen_fake('example.pdf')
+        rtn = IS_FILE(extension='pdf')(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('example.gif')
+        rtn = IS_FILE(extension='pdf')(fake)
+        self.assertEqual(rtn, (fake, 'Enter valid filename'))
+        fake = gen_fake('multiple.pdf')
+        rtn = IS_FILE(extension=['pdf', 'png'])(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('multiple.png')
+        rtn = IS_FILE(extension=['pdf', 'png'])(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('multiple.gif')
+        rtn = IS_FILE(extension=['pdf', 'png'])(fake)
+        self.assertEqual(rtn, (fake, 'Enter valid filename'))
+        fake = gen_fake('backup2014.tar.gz')
+        rtn = IS_FILE(filename=re.compile('backup.*'), extension='tar.gz', lastdot=False)(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('README')
+        rtn = IS_FILE(filename='README', extension='', case=0)(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('readme')
+        rtn = IS_FILE(filename='README', extension='', case=0)(fake)
+        self.assertEqual(rtn, (fake, 'Enter valid filename'))
+        fake = gen_fake('readme')
+        rtn = IS_FILE(filename='README', case=2)(fake)
+        self.assertEqual(rtn, (fake, None))
+        fake = gen_fake('README')
+        rtn = IS_FILE(filename='README', case=2)(fake)
+        self.assertEqual(rtn, (fake, None))
+        rtn = IS_FILE(extension='pdf')('example.pdf')
+        self.assertEqual(rtn, ('example.pdf', 'Enter valid filename'))
+
     def test_IS_UPLOAD_FILENAME(self):
         import cgi
         from io import BytesIO