source: ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/app.py @ 7024

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/ceda_http_fileserver/trunk/ceda_http_fileserver/ceda/server/wsgi/fileserver/app.py@7024
Revision 7024, 17.2 KB checked in by pjkersha, 10 years ago (diff)

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
  • Property svn:keywords set to Id
Line 
1"""CEDA (Centre for Environmental Data Archival) File Server WSGI Application
2module
3"""
4__author__ = "P J Kershaw"
5__date__ = "11/06/10"
6__copyright__ = "(C) 2010 Science and Technology Facilities Council"
7__license__ = """http://www.apache.org/licenses/LICENSE-2.0"""
8__contact__ = "Philip.Kershaw@stfc.ac.uk"
9__revision__ = '$Id$'
10#   Copyright (c) 2006-2007 Open Source Applications Foundation
11#
12#   Licensed under the Apache License, Version 2.0 (the "License");
13#   you may not use this file except in compliance with the License.
14#   You may obtain a copy of the License at
15#
16#       http://www.apache.org/licenses/LICENSE-2.0
17#
18#   Unless required by applicable law or agreed to in writing, software
19#   distributed under the License is distributed on an "AS IS" BASIS,
20#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21#   See the License for the specific language governing permissions and
22#   limitations under the License.
23
24from urlparse import urlparse
25import httplib
26import urllib
27import os
28import traceback
29import mimetypes
30import errno
31import re
32import logging
33
34log = logging.getLogger(__name__)
35
36
37def reconstruct_url(environ):
38    """Make URL from environ keys"""
39    # From WSGI spec, PEP 333
40    url = environ['wsgi.url_scheme']+'://'
41    if environ.get('HTTP_HOST'): 
42        url += environ['HTTP_HOST']
43    else:
44        url += environ['SERVER_NAME']
45        if environ['wsgi.url_scheme'] == 'https':
46            if environ['SERVER_PORT'] != '443':
47                url += ':' + environ['SERVER_PORT']
48        else:
49            if environ['SERVER_PORT'] != '80':
50                url += ':' + environ['SERVER_PORT']
51               
52    url += urllib.quote(environ.get('SCRIPT_NAME',''))
53   
54    pathInfo = environ.get('PATH_INFO','')
55    url += urllib.quote(pathInfo).replace(url.replace(':', '%3A'), '')
56   
57    if environ.get('QUERY_STRING'):
58        url += '?' + environ['QUERY_STRING']
59    environ['reconstructed_url'] = url
60   
61    return url
62   
63   
64class FileResponse(object):
65    """Helper class creates iterable response based on a given block size"""
66    DEFAULT_READ_SIZE = 1024
67   
68    def __init__(self, readSize=DEFAULT_READ_SIZE):
69        self.fileObj = None
70        self.fileSize = None
71        self.readSize = readSize
72
73    def __call__(self, fileObj, fileName):
74        self.fileSize = os.path.getsize(fileName)
75        self.fileObj = fileObj
76       
77        return self 
78       
79    def __iter__(self):
80        output = '\n'
81        while len(output) is not 0:
82            output = self.fileObj.read(self.readSize)
83            yield output
84
85    def _getReadSize(self):
86        return self.__readSize
87   
88    def _setReadSize(self, value):
89        self.__readSize = int(value)
90        if self.__readSize < 0:
91            raise ValueError('Expecting positive integer value for block size '
92                             'attribute')
93           
94    readSize = property(fget=_getReadSize, fset=_setReadSize,
95                        doc="block size reading the file in the iterator and "
96                            "returning a response")
97
98
99# Map HTTP status code to code + standard message string
100_statusCode2Msg = lambda code: "%d %s" % (code, 
101                    httplib.responses.get(code, httplib.INTERNAL_SERVER_ERROR))
102           
103           
104class FileServerApp(object):
105    """Application to serve static content"""
106    PARAM_PREFIX = 'fileserver.'
107    FILE_SYS_ROOT_PATH_OPTNAME = 'fileSysRootPath'
108   
109    DEFAULT_READ_BLK_SIZE = 1024
110    DEFAULT_WRITE_BLK_SIZE = 1024
111    DEFAULT_CONTENT_TYPE = 'text/plain; charset=utf-8'
112    DEFAULT_MOUNT_POINT = '/'
113   
114    statusCode2Msg = staticmethod(_statusCode2Msg)
115   
116    # Map HTTP status string to a given file system file access error
117    FILE_ACCESS_ERROR_MAP = {
118         errno.ENOENT: _statusCode2Msg(httplib.NOT_FOUND),
119         errno.EACCES: _statusCode2Msg(httplib.FORBIDDEN)
120    }
121   
122    __slots__ = (
123        '__fileResponse',
124        '__readBlkSize',
125        '__httpMethodMap',
126        '__fileSysRoot',
127        '__mountPoint',
128        '__mimeTypes',
129        '__fileFilterRegEx'
130    )
131   
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
141        self.__fileResponse = FileResponse()
142        self.__readBlkSize = None
143        self.__httpMethodMap = None
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       
155        self.mountPoint = mountPoint
156       
157        # Set from property methods to apply validation - referencing
158        # self.__class__ means derived class could make an alternative setting
159       
160        # Block size for PUT or POST operations
161        self.readBlkSize = readBlkSize
162
163        # Block size for GET operation
164        self.writeBlkSize = writeBlkSize
165       
166        # Map HTTP method name to a method of this class
167        self.httpMethodMap = self.__class__.DEFAULT_HTTP_METHOD_MAP
168       
169        self.fileFilterRegEx = fileFilterRegEx
170       
171        # MIME types
172        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")
233
234    def _getReadBlkSize(self):
235        return self.__readBlkSize
236
237    def _setReadBlkSize(self, value):
238        self.__readBlkSize = int(value)
239        if self.__readBlkSize < 0:
240            raise ValueError('Expecting positive integer value for block size '
241                             'attribute')
242
243    readBlkSize = property(_getReadBlkSize, _setReadBlkSize, 
244                           doc="Block size for read files for upload")
245
246    def _getWriteBlkSize(self):
247        return self.__fileResponse.readSize
248
249    def _setWriteBlkSize(self, value):
250        self.__fileResponse.readSize = value
251       
252    writeBlkSize = property(_getWriteBlkSize, _setWriteBlkSize, 
253                            doc="Block size for writing files for download")
254
255    def _getHttpMethodMap(self):
256        return self.__httpMethodMap
257
258    def _setHttpMethodMap(self, value):
259        if not isinstance(value, dict):
260            raise TypeError('Expecting dict type for HTTP method map '
261                            'attribute; got %r' % type(value))
262           
263        for name, method in value.items():
264            if not isinstance(name, basestring):
265                raise TypeError('Expecting string type for HTTP method name; '
266                                'got %r' % type(name))
267                   
268            if not callable(method):
269                raise TypeError('Expecting callable for HTTP method ; got %r' % 
270                                type(method))
271                               
272        self.__httpMethodMap = value.copy()
273
274    httpMethodMap = property(_getHttpMethodMap, _setHttpMethodMap, 
275                             doc="Dictionary mapping HTTP method names to "
276                                 "methods of this class")
277
278    @classmethod
279    def app_factory(cls, global_conf, prefix=PARAM_PREFIX, **app_conf): 
280        """Function following Paste app factory signature
281       
282        @type global_conf: dict       
283        @param global_conf: PasteDeploy global configuration dictionary
284        @type prefix: basestring
285        @param prefix: prefix for configuration items
286        @type app_conf: dict       
287        @param app_conf: PasteDeploy application specific configuration
288        dictionary
289        """
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)
299
300        return app
301   
302    def handler(self, environ, start_response):
303        """Application to serve out windmill provided"""
304        url = urlparse(reconstruct_url(environ))
305       
306        if self.mountPoint is not None:
307            #split_url = url.path.split(self.mountPoint, 1)
308            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               
322            relativeURI = split_url[1]
323        else:
324            relativeURI = url[2]
325       
326        fileSysSubDir = urllib.url2pathname(relativeURI)
327       
328        # This if statement stops os.path.join doing a join with an
329        # absolute path '/...'.  If this is done, the first argument is
330        # obliterated and the result is '/' exposing the root file system to
331        # the web client!!
332        if fileSysSubDir.startswith('/'):
333            fileSysSubDir = fileSysSubDir[1:]
334               
335       
336        requestMethodName = environ['REQUEST_METHOD']
337        requestMethod = self.httpMethodMap.get(requestMethodName)
338        if requestMethod is None:
339            response = ('%r HTTP request method is not supported' % 
340                        requestMethodName)
341            status = FileServerApp.statusCode2Msg(httplib.METHOD_NOT_ALLOWED)
342            start_response(status,
343                           [('Content-length', str(len(response))),
344                            ('Content-type', 'text/plain')])
345            return [response]
346           
347        return requestMethod(self, fileSysSubDir, environ, start_response)
348       
349    def do_get(self, fileSysSubDir, environ, start_response):
350        """HTTP GET callback"""
351        filePath = os.path.join(self.fileSysRoot, fileSysSubDir)
352       
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):
371            dirContents = os.listdir(filePath)
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
377            lines = [
378                '<a href="%s%s/%s">%s</a>' % 
379                (scriptName,
380                 mountPoint,
381                 urllib.pathname2url(os.path.join(fileSysSubDir, filename)),
382                 filename) 
383                for filename in dirContents if _allowFile('/' + filename)
384            ]
385
386            response = ('<html><head/><body>%s</body></html>' % 
387                        '<br>'.join(lines))
388   
389            start_response('200 OK', 
390                           [('Cache-Control','no-cache'), 
391                            ('Pragma','no-cache'),
392                            ('Content-Type', 'text/html; charset=utf-8'),
393                            ('Content-Length', str(len(response)))])
394           
395            return [response]
396        else:
397            try:
398                fileObj = open(filePath, 'rb')
399                log.debug('opened file %s', filePath)
400               
401            except IOError, e:
402                # Map file access error to a HTTP response code
403                status = FileServerApp.mapFileAccessError2HttpStatus(e.errno)
404                log.error('failed to open file %r: %s', filePath, 
405                          traceback.format_exc())
406                response = status
407                start_response(status, 
408                               [('Content-Type', 'text/plain'),
409                                ('Content-length', str(len(response)))])
410                return [response]
411           
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
420       
421    def do_put(self, fileSysSubDir, environ, start_response):
422        """HTTP PUT callback"""
423        try:
424            filePath = os.path.join(self.fileSysRoot, fileSysSubDir)
425            fileObj = open(filePath, 'wb')
426            log.debug('opened file for writing %s', filePath)
427           
428        except IOError, e:
429            log.error('failed to open file for writing %r', filePath)
430            status = FileServerApp.mapFileAccessError2HttpStatus(e.errno)
431            response = status
432            start_response(status, 
433                           [('Content-Type', 'text/plain'),
434                            ('Content-length', str(len(response)))])
435            return [response]
436       
437        inputLength = environ['CONTENT_LENGTH']
438        inputStream = environ['wsgi.input']
439        nReads = inputLength / self.readBlkSize
440        remainder = inputLength % self.readBlkSize
441        readArray = [self.readBlkSize] * nReads
442        if remainder > 0:
443            nReads += 1
444            readArray.append(remainder)
445           
446        for length in readArray:
447            inputBlk = inputStream.read(length)
448            fileObj.write(inputBlk)
449
450    @classmethod
451    def mapFileAccessError2HttpStatus(cls, errno):
452        """Map file access error to a standard HTTP response status message"""
453        return cls.FILE_ACCESS_ERROR_MAP.get(errno, 
454                            cls.statusCode2Msg(httplib.INTERNAL_SERVER_ERROR))
455                                                       
456    def getContentType(self, path_info):
457        """Make a best guess at the content type"""
458        contentType = self.mimeTypes.guess_type(path_info)[0]
459        if contentType is not None:
460            return contentType
461        else:
462            return self.__class__.DEFAULT_CONTENT_TYPE
463           
464    def __call__(self, environ, start_response):
465        return self.handler(environ, start_response)
466       
467    DEFAULT_HTTP_METHOD_MAP = {
468        'GET':          do_get, 
469        'PUT':          do_put, 
470    }
Note: See TracBrowser for help on using the repository browser.