Changeset 7024 for ceda_http_fileserver


Ignore:
Timestamp:
15/06/10 10:36:08 (9 years ago)
Author:
pjkersha
Message:

Incomplete - task 9: Data Browser Replacement: ready for first release -

  • added regular expression filtering to avoid exposing unwanted content over HTTP interface
  • tested in paster test harness
  • tested PasteDeploy? interface
  • deleting remaining original wsgi-fileserver code
Location:
ceda_http_fileserver/trunk/ceda_http_fileserver
Files:
1 added
1 deleted
5 edited

Legend:

Unmodified
Added
Removed
  • ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/app.py

    r7022 r7024  
    2222#   limitations under the License. 
    2323 
    24 from urlparse import urlparse, urljoin 
     24from urlparse import urlparse 
    2525import httplib 
    2626import urllib 
     
    2929import mimetypes 
    3030import errno 
     31import re 
    3132import logging 
    3233 
     
    3536 
    3637def reconstruct_url(environ): 
     38    """Make URL from environ keys""" 
    3739    # From WSGI spec, PEP 333 
    38     from urllib import quote 
    3940    url = environ['wsgi.url_scheme']+'://' 
    4041    if environ.get('HTTP_HOST'):  
     
    4445        if environ['wsgi.url_scheme'] == 'https': 
    4546            if environ['SERVER_PORT'] != '443': 
    46                url += ':' + environ['SERVER_PORT'] 
     47                url += ':' + environ['SERVER_PORT'] 
    4748        else: 
    4849            if environ['SERVER_PORT'] != '80': 
    49                url += ':' + environ['SERVER_PORT'] 
     50                url += ':' + environ['SERVER_PORT'] 
     51                
    5052    url += urllib.quote(environ.get('SCRIPT_NAME','')) 
    51     url += urllib.quote(environ.get('PATH_INFO','')).replace(url.replace(':', '%3A'), '') 
     53     
     54    pathInfo = environ.get('PATH_INFO','') 
     55    url += urllib.quote(pathInfo).replace(url.replace(':', '%3A'), '') 
     56     
    5257    if environ.get('QUERY_STRING'): 
    5358        url += '?' + environ['QUERY_STRING'] 
    5459    environ['reconstructed_url'] = url 
     60     
    5561    return url 
    5662     
    5763     
    5864class FileResponse(object): 
     65    """Helper class creates iterable response based on a given block size""" 
    5966    DEFAULT_READ_SIZE = 1024 
    6067     
     
    98105    """Application to serve static content""" 
    99106    PARAM_PREFIX = 'fileserver.' 
     107    FILE_SYS_ROOT_PATH_OPTNAME = 'fileSysRootPath' 
     108     
    100109    DEFAULT_READ_BLK_SIZE = 1024 
    101110    DEFAULT_WRITE_BLK_SIZE = 1024 
    102111    DEFAULT_CONTENT_TYPE = 'text/plain; charset=utf-8' 
     112    DEFAULT_MOUNT_POINT = '/' 
    103113     
    104114    statusCode2Msg = staticmethod(_statusCode2Msg) 
     
    114124        '__readBlkSize', 
    115125        '__httpMethodMap', 
    116         'fileSysRoot', 
    117         'mountPoint', 
    118         'mimeTypes' 
     126        '__fileSysRoot', 
     127        '__mountPoint', 
     128        '__mimeTypes', 
     129        '__fileFilterRegEx' 
    119130    ) 
    120131     
    121      
    122     def __init__(self, root_path, mountPoint=None): 
    123          
     132    def __init__(self,  
     133                 fileSysRootPath,  
     134                 mountPoint=DEFAULT_MOUNT_POINT, 
     135                 readBlkSize=DEFAULT_READ_BLK_SIZE, 
     136                 writeBlkSize=DEFAULT_WRITE_BLK_SIZE, 
     137                 fileFilterRegEx=None, 
     138                 mimeTypesFilePath=None): 
     139         
     140        # Initialise all attributes here 
    124141        self.__fileResponse = FileResponse() 
    125142        self.__readBlkSize = None 
    126143        self.__httpMethodMap = None 
    127          
    128         self.fileSysRoot = os.path.abspath(os.path.expanduser(root_path)) 
     144        self.__mountPoint = None 
     145        self.__fileSysRoot = None 
     146        self.__fileFilterRegEx = None 
     147         
     148        # ... Then set default values here 
     149        self.fileSysRoot = os.path.abspath( 
     150                                os.path.expandvars( 
     151                                    os.path.expanduser(fileSysRootPath) 
     152                                    ) 
     153                                ) 
     154         
    129155        self.mountPoint = mountPoint 
    130156         
     
    133159         
    134160        # Block size for PUT or POST operations 
    135         self.readBlkSize = self.__class__.DEFAULT_READ_BLK_SIZE 
     161        self.readBlkSize = readBlkSize 
    136162 
    137163        # Block size for GET operation 
    138         self.writeBlkSize = self.__class__.DEFAULT_WRITE_BLK_SIZE 
     164        self.writeBlkSize = writeBlkSize 
    139165         
    140166        # Map HTTP method name to a method of this class 
    141167        self.httpMethodMap = self.__class__.DEFAULT_HTTP_METHOD_MAP 
    142168         
     169        self.fileFilterRegEx = fileFilterRegEx 
     170         
    143171        # MIME types 
    144172        self.mimeTypes = mimetypes.MimeTypes() 
     173         
     174        if mimeTypesFilePath is not None: 
     175            self.mimeTypes.read(mimeTypesFilePath) 
     176 
     177    def _getFileFilterRegEx(self): 
     178        return self.__fileFilterRegEx 
     179 
     180    def _setFileFilterRegEx(self, value): 
     181        if value is None: 
     182            self.__fileFilterRegEx = value 
     183         
     184        elif isinstance(value, basestring): 
     185            self.__fileFilterRegEx = re.compile(value) 
     186        else:  
     187            raise TypeError('Expecting string or None type for ' 
     188                            '"fileFilterRegEx" attribute; got %r' % type(value)) 
     189 
     190         
     191 
     192    fileFilterRegEx = property(_getFileFilterRegEx, _setFileFilterRegEx,  
     193                               doc="Regular expression to filter out matching " 
     194                                   "files from being served") 
     195 
     196    def _getMimeTypes(self): 
     197        return self.__mimeTypes 
     198 
     199    def _setMimeTypes(self, value): 
     200        self.__mimeTypes = value 
     201 
     202    mimeTypes = property(_getMimeTypes, _setMimeTypes,  
     203                         doc="Mime types object for determining response " 
     204                             "content type from file extension") 
     205 
     206    def _getFileSysRoot(self): 
     207        return self.__fileSysRoot 
     208 
     209    def _setFileSysRoot(self, value): 
     210        if not isinstance(value, basestring): 
     211            raise TypeError('Expecting string type for "fileSysRoot" ' 
     212                            'attribute; got %r' % type(value)) 
     213 
     214        self.__fileSysRoot = value 
     215 
     216    fileSysRoot = property(_getFileSysRoot, _setFileSysRoot,  
     217                           doc="Root directory in file system from which to " 
     218                               "serve files") 
     219 
     220    def _getMountPoint(self): 
     221        return self.__mountPoint 
     222 
     223    def _setMountPoint(self, value): 
     224        if not isinstance(value, basestring): 
     225            raise TypeError('Expecting string type for "mountPoint" ' 
     226                            'attribute; got %r' % type(value)) 
     227             
     228        self.__mountPoint = value 
     229 
     230    mountPoint = property(_getMountPoint, _setMountPoint,  
     231                          doc="Mount point - root path for URI to serve files " 
     232                              "from") 
    145233 
    146234    def _getReadBlkSize(self): 
     
    154242 
    155243    readBlkSize = property(_getReadBlkSize, _setReadBlkSize,  
    156                            doc="ReadBlkSize's Docstring") 
     244                           doc="Block size for read files for upload") 
    157245 
    158246    def _getWriteBlkSize(self): 
     
    163251         
    164252    writeBlkSize = property(_getWriteBlkSize, _setWriteBlkSize,  
    165                             doc="WriteBlkSize's Docstring") 
     253                            doc="Block size for writing files for download") 
    166254 
    167255    def _getHttpMethodMap(self): 
     
    185273 
    186274    httpMethodMap = property(_getHttpMethodMap, _setHttpMethodMap,  
    187                              doc="HttpMethodMap's Docstring") 
     275                             doc="Dictionary mapping HTTP method names to " 
     276                                 "methods of this class") 
    188277 
    189278    @classmethod 
     
    199288        dictionary 
    200289        """ 
    201         # This app               
    202         app = cls(**app_conf) 
     290        # Filter based on prefix 
     291        if prefix: 
     292            prefixLen = len(prefix) 
     293            kw = dict([(k[prefixLen:], v) for k, v in app_conf.items()  
     294                       if k.startswith(prefix)]) 
     295             
     296        # This app   
     297        fileSysRootPath = kw.pop(cls.FILE_SYS_ROOT_PATH_OPTNAME)          
     298        app = cls(fileSysRootPath, **kw) 
    203299 
    204300        return app 
     
    211307            #split_url = url.path.split(self.mountPoint, 1) 
    212308            split_url = url[2].split(self.mountPoint, 1) 
     309             
     310            if len(split_url) < 2: 
     311                log.error('Error splitting URI %r with mount point %r: ' 
     312                          'returning 404 Not Found response', 
     313                          url, self.mountPoint) 
     314                 
     315                status = FileServerApp.statusCode2Msg(httplib.NOT_FOUND) 
     316                response = status 
     317                start_response(status, 
     318                               [('Content-length', str(len(response))), 
     319                                ('Content-type', 'text/plain')]) 
     320                return [response] 
     321                 
    213322            relativeURI = split_url[1] 
    214323        else: 
     
    242351        filePath = os.path.join(self.fileSysRoot, fileSysSubDir) 
    243352         
    244         if os.path.isdir(filePath): 
     353        scriptName = environ.get('SCRIPT_NAME', '') 
     354         
     355        # Apply filter to filter out unwanted content 
     356        pat = self.fileFilterRegEx 
     357        if pat is not None: 
     358            _allowFile = lambda filename: pat.match(filename) is None 
     359        else: 
     360            _allowFile = lambda filename: True 
     361             
     362        if not _allowFile(fileSysSubDir): 
     363            status = FileServerApp.statusCode2Msg(httplib.NOT_FOUND) 
     364            response = status 
     365            start_response(status,  
     366                           [('Content-Type', 'text/plain'), 
     367                            ('Content-length', str(len(response)))]) 
     368            return [response] 
     369             
     370        elif os.path.isdir(filePath): 
    245371            dirContents = os.listdir(filePath) 
    246  
     372            mountPoint = self.mountPoint 
     373             
     374            # Prepend '/' to pattern match test - it makes it easier to write 
     375            # a generic regular expression for filtering out both  
     376            # sub-directories - see above - and file names as here 
    247377            lines = [ 
    248                 '<a href="/%s">%s</a>' %  
    249                 (urllib.pathname2url(os.path.join(fileSysSubDir, filename)), 
     378                '<a href="%s%s/%s">%s</a>' %  
     379                (scriptName, 
     380                 mountPoint, 
     381                 urllib.pathname2url(os.path.join(fileSysSubDir, filename)), 
    250382                 filename)  
    251                 for filename in dirContents 
     383                for filename in dirContents if _allowFile('/' + filename) 
    252384            ] 
    253385 
    254             response = '<html>' + '<br>'.join(lines)+ '</html>' 
     386            response = ('<html><head/><body>%s</body></html>' %  
     387                        '<br>'.join(lines)) 
     388     
    255389            start_response('200 OK',  
    256390                           [('Cache-Control','no-cache'),  
     
    260394             
    261395            return [response] 
    262         else:         
     396        else: 
    263397            try: 
    264398                fileObj = open(filePath, 'rb') 
    265399                log.debug('opened file %s', filePath) 
     400                 
    266401            except IOError, e: 
    267402                # Map file access error to a HTTP response code 
     
    275410                return [response] 
    276411             
    277             response = self.__fileResponse(fileObj, filePath) 
    278             start_response('200 OK',  
    279                            [('Cache-Control','no-cache'),  
    280                             ('Pragma','no-cache'),  
    281                             ('Content-Length', str(response.fileSize)), 
    282                             ('Content-Type',  
    283                              self.getContentType(environ['PATH_INFO']))]) 
    284             return response 
     412            self.__fileResponse(fileObj, filePath) 
     413            start_response(FileServerApp.statusCode2Msg(httplib.OK),  
     414                       [('Cache-Control','no-cache'),  
     415                        ('Pragma','no-cache'),  
     416                        ('Content-Length', str(self.__fileResponse.fileSize)), 
     417                        ('Content-Type',  
     418                         self.getContentType(environ['PATH_INFO']))]) 
     419            return self.__fileResponse 
    285420         
    286421    def do_put(self, fileSysSubDir, environ, start_response): 
  • ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/test/fileserver.ini

    r6999 r7024  
    1515port = 5000 
    1616 
    17 [app:FileServerApp] 
    18 paste.app_factory = ceda.server.wsgi.fileserver.app:FileServerApp 
    19 root_path= 
    20 mount_point= 
     17[app:main] 
     18paste.app_factory = ceda.server.wsgi.fileserver.app:FileServerApp.app_factory 
     19prefix = fileserver-app. 
     20fileserver-app.fileSysRootPath=%(here)s/htdocs 
     21fileserver-app.mountPoint=/fileserver 
     22 
     23# Filter out dot file content  
     24fileserver-app.fileFilterRegEx=.*/\..*$ 
     25 
     26# Ridiculously small block sizes for testing 
     27fileserver-app.readBlkSize=8 
     28fileserver-app.writeBlkSize=8 
     29 
     30# Logging configuration 
     31[loggers] 
     32keys = root, ceda 
     33 
     34[handlers] 
     35keys = console 
     36 
     37[formatters] 
     38keys = generic 
     39 
     40[logger_root] 
     41level = INFO 
     42handlers = console 
     43 
     44[logger_ceda] 
     45level = DEBUG 
     46handlers = 
     47qualname = ceda 
     48 
     49[handler_console] 
     50class = StreamHandler 
     51args = (sys.stderr,) 
     52level = NOTSET 
     53formatter = generic 
     54 
     55[formatter_generic] 
     56format = %(asctime)s.%(msecs)03d %(levelname)-5.5s [%(name)s:%(lineno)s] %(message)s 
     57datefmt = %Y-%m-%d %H:%M:%S 
  • ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/test/htdocs/my file.html

    r7022 r7024  
     1<html> 
     2    <head> 
     3        <title>CEDA File Server application Unit Tests</title> 
     4    </head> 
     5    <body> 
     6        <h1>CEDA File Server application Unit Tests</h1> 
     7    </body> 
     8</html> 
  • ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/test/test_fileserver.py

    r7022 r7024  
    1616from os import path 
    1717import os 
     18import socket 
    1819import httplib 
    1920import urllib 
     21import urllib2 
    2022 
    2123import paste.fixture 
    2224from paste.deploy import loadapp 
    2325 
     26from ceda.server.wsgi.fileserver.test import PasteDeployAppServer 
    2427from ceda.server.wsgi.fileserver.app import FileServerApp 
    2528 
    2629 
    27 class FileServerAppTestCase(unittest.TestCase): 
     30class FileServerAppTestCaseBase(unittest.TestCase): 
    2831    THIS_DIR = path.abspath(path.dirname(__file__)) 
     32     
    2933    HTDOCS_DIRNAME = 'htdocs' 
    3034    HTDOCS_DIR = path.join(THIS_DIR, HTDOCS_DIRNAME) 
     35     
    3136    HTDOCS_SUBDIR1 = 'sub.dir-1' 
     37    HTDOCS_SUBDIR1_URIPATH = '/' + HTDOCS_SUBDIR1 
     38     
    3239    HTDOCS_SUBDIR2 = path.join('sub.dir-1', '.sub-dir2') 
    3340    HTDOCS_SUBDIR3 = path.join('sub.dir-1', 'SubDir 3') 
     41    HTDOCS_SUBDIR3_URIPATH = '/' + urllib.pathname2url(HTDOCS_SUBDIR3) 
    3442     
    3543    PNG_REL_FILEPATH = path.join(HTDOCS_SUBDIR1, 
     
    4856    PDF_FILENAME = 'a test PDF.pdf' 
    4957    PDF_REL_FILEPATH = path.join(HTDOCS_SUBDIR3, PDF_FILENAME) 
    50     PDF_REL_URIPATH = '/' + urllib.pathname2url(PDF_REL_FILEPATH) 
     58    PDF_REL_URIPATH = '/' + urllib.pathname2url(PDF_REL_FILEPATH)    
     59      
     60class FileServerAppTestCase(FileServerAppTestCaseBase): 
     61    """Test ceda.server.wsgi.fileserver.app.FileServerApp with Paste Fixture 
     62    """ 
    5163     
    5264    def __init__(self, *args, **kwargs): 
     
    5466        self.app = paste.fixture.TestApp(self.fileServerApp) 
    5567          
    56         unittest.TestCase.__init__(self, *args, **kwargs) 
     68        FileServerAppTestCaseBase.__init__(self, *args, **kwargs) 
    5769 
    5870    def test01Assert(self): 
     
    98110        # Test writing output from server in blocks 
    99111        self.fileServerApp.writeBlkSize = 8 
     112         
    100113        localFilePath = path.join(self.__class__.HTDOCS_DIR,  
    101114                                  self.__class__.PNG_REL_FILEPATH) 
    102115        fileContent = open(localFilePath, 'rb').read() 
     116         
    103117        response = self.app.get(self.__class__.PNG_REL_URIPATH,  
    104118                                status=httplib.OK) 
     119         
    105120        print(response.headers) 
    106121        print(response.body) 
     
    115130    def test09ListSubDir(self): 
    116131        # list content of a sub-directory 
    117         subDirUriPath = '/' + urllib.pathname2url(self.__class__.HTDOCS_SUBDIR3) 
    118         response = self.app.get(subDirUriPath, status=httplib.OK) 
     132        response = self.app.get(self.__class__.HTDOCS_SUBDIR3_URIPATH,  
     133                                status=httplib.OK) 
    119134        print(response.body) 
    120135        self.assert_(self.__class__.PDF_FILENAME in response.body) 
     
    122137    def test10PathFilter(self): 
    123138        # Test filter which filters out path matching a given regular expression 
    124         pass 
     139        self.fileServerApp.fileFilterRegEx = '.*\.(PNG|png)' 
     140         
     141        # Test direct access to the JPEG URI 
     142        response1 = self.app.get(self.__class__.PNG_REL_URIPATH,  
     143                                 status=httplib.NOT_FOUND) 
     144         
     145        print(response1.headers) 
     146        print(response1.body) 
     147         
     148        # Now test the list for the PNG file's directory 
     149        response2 = self.app.get(self.__class__.HTDOCS_SUBDIR1_URIPATH,  
     150                                 status=httplib.OK) 
     151         
     152        print(response2.headers) 
     153        print(response2.body) 
     154         
     155        self.assert_(self.__class__.PNG_REL_FILEPATH not in response2.body) 
     156         
    125157      
    126158    def test11AccessForbidden(self): 
     
    147179        # Test with alternative mount point to the default '/' 
    148180        self.fileServerApp.mountPoint = '/file-server' 
     181        requestPath = '/file-server'+ self.__class__.HTDOCS_SUBDIR3_URIPATH 
     182         
     183        response = self.app.get(requestPath, status=httplib.OK) 
     184        print(response.body) 
     185        self.assert_(self.__class__.PDF_FILENAME in response.body) 
    149186                
    150187         
    151 class FileServerAppTestCaseBase(unittest.TestCase):   
     188class FileServerAppPasterTestCase(FileServerAppTestCaseBase):   
    152189    """Base class for common Paste Deploy related set-up""" 
    153     THIS_DIR = path.abspath(path.dirname(__file__)) 
    154190    INI_FILENAME = 'fileserver.ini' 
    155     INI_FILEPATH = path.join(THIS_DIR, INI_FILENAME) 
     191    INI_FILEPATH = path.join(FileServerAppTestCaseBase.THIS_DIR, INI_FILENAME) 
     192     
     193    SERVICE_PORTNUM = 5080 
     194    MOUNT_POINT = '/fileserver' 
     195    URI_BASE = 'http://localhost:%d%s' % (SERVICE_PORTNUM, MOUNT_POINT) 
    156196     
    157197    def __init__(self, *args, **kwargs): 
    158198        wsgiapp = loadapp('config:' + self.__class__.INI_FILEPATH) 
    159199        self.app = paste.fixture.TestApp(wsgiapp) 
     200         
     201        self.services = [] 
     202         
     203        # Start the file server in a separate thread 
     204        self.addService(cfgFilePath=self.__class__.INI_FILEPATH,  
     205                        port=self.__class__.SERVICE_PORTNUM) 
    160206                 
    161         unittest.TestCase.__init__(self, *args, **kwargs)   
    162  
    163  
     207        FileServerAppTestCaseBase.__init__(self, *args, **kwargs)   
     208        
     209    def test01GetPNG(self): 
     210        uri = self.__class__.URI_BASE + self.__class__.PNG_REL_URIPATH 
     211        response = urllib2.urlopen(uri) 
     212        self.assert_(response) 
     213        body = response.read() 
     214        self.assert_(len(body) > 0) 
     215        self.assert_(response.code == httplib.OK) 
     216        
     217    def test02MissingMountPoint(self): 
     218        # Try request with incorrect path missing out mount point 
     219        uri = 'http://localhost:%d%s' % (self.__class__.SERVICE_PORTNUM, 
     220                                         self.__class__.PNG_REL_URIPATH) 
     221        self.assertRaises(urllib2.HTTPError, urllib2.urlopen, uri) 
     222         
     223    def addService(self, *arg, **kw): 
     224        """Utility for setting up threads to run Paste HTTP based services with 
     225        unit tests 
     226         
     227        @param arg: tuple contains ini file path setting for the service 
     228        @type arg: tuple 
     229        @param kw: keywords including "port" - port number to run the service  
     230        from 
     231        @type kw: dict 
     232        """ 
     233        try: 
     234            self.services.append(PasteDeployAppServer(*arg, **kw)) 
     235            self.services[-1].startThread() 
     236             
     237        except socket.error: 
     238            pass 
     239 
     240    def __del__(self): 
     241        """Stop any services started with the addService method  
     242        """ 
     243        if hasattr(self, 'services'): 
     244            for service in self.services: 
     245                service.terminateThread() 
     246                 
     247                 
    164248if __name__ == "__main__": 
    165249    unittest.main() 
Note: See TracChangeset for help on using the changeset viewer.