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

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

Incomplete - task 9: Data Browser Replacement

  • fixed client byte range requests bug - added 'bytes=' prefix and added support for default start and end indices e.g. bytes=-9 or bytes=100-
  • 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
37class FileResponseError(Exception):
38    """Base exception type for exceptions raised from FileResponse class
39    instances"""
40   
41class InvalidRangeRequest(FileResponseError):
42    """Raise for an invalid byte range requested"""
43    def __init__(self, *arg, **kw):
44        FileResponseError.__init__(self, *arg, **kw)
45        if len(arg) > 1:
46            self.contentRangeHdr = arg[1]
47        else:
48            self.contentRangeHdr = None
49           
50   
51class InvalidRangeRequestSyntax(FileResponseError):
52    """Raise for invalid range request syntax"""
53   
54   
55class FileResponse(object):
56    """Helper class creates iterable response based on a given block size"""
57    DEFAULT_BLK_SIZE = 1024
58    BYTE_RANGE_PREFIX = 'bytes='
59    BYTE_RANGE_SEP = '-'
60    CONTENT_RANGE_FIELDNAME = 'Content-range'
61    CONTENT_RANGE_FORMAT_STR = "bytes %d-%d/%d"
62    INVALID_CONTENT_RANGE_FORMAT_STR = "bytes */%d"
63   
64    __slots__ = (
65        'fileObj',
66        'fileSize',
67        '__blkSize',
68        'readLengths',
69        'contentLength',
70        'contentRange',
71        'contentRangeHdr',
72    )
73   
74    def __init__(self, filePath, requestRange=None, blkSize=DEFAULT_BLK_SIZE):
75        '''Open a file and set the blocks for reading, any input range set and
76        the response size
77        '''
78        self.fileObj = None
79        self.fileSize = None
80       
81        # the length of the content to return - this will be different to the
82        # file size if the client a byte range header field setting
83        self.contentLength = 0
84       
85        # None unless a valid input range was given
86        self.contentRange = None
87       
88        # Formatted for HTTP content range header field
89        self.contentRangeHdr = None
90
91        # This will call the relevant set property method
92        self.blkSize = blkSize
93       
94        # Array of blocks lengths for iterator to use to read the file
95        self.readLengths = []
96       
97        try:
98            self.fileObj = open(filePath, 'rb')
99            log.debug('Opened file %s', filePath)
100           
101        except IOError:
102            log.error('Failed to open file %r: %s', filePath, 
103                      traceback.format_exc()) 
104            raise   
105       
106        self.fileSize = os.path.getsize(filePath)
107        if requestRange is not None:
108           
109            # Prepare a content range header in case the range specified is
110            # invalid
111            contentRangeHdr = (self.__class__.CONTENT_RANGE_FIELDNAME,
112                               self.__class__.INVALID_CONTENT_RANGE_FORMAT_STR %
113                               self.fileSize)
114                               
115            try:
116                # Remove 'bytes=' prefix
117                rangeVals = requestRange.split(
118                                        self.__class__.BYTE_RANGE_PREFIX)[-1]
119                                       
120                # Convert into integers taking into account that a value may be
121                # absent
122                startStr, endStr = rangeVals.split(
123                                                self.__class__.BYTE_RANGE_SEP)
124                start = int(startStr or 0)
125                end = int(endStr or self.fileSize - 1)
126            except ValueError:
127                raise InvalidRangeRequestSyntax('Invalid format for request '
128                                                'range %r' % requestRange)
129           
130            # Verify range bounds
131            if start > end:
132                raise InvalidRangeRequest('Range start index %r is greater '
133                                          'than the end index %r for file %r' % 
134                                          (start, end, filePath),
135                                          contentRangeHdr)
136            elif start < 0:
137                raise InvalidRangeRequest('Range start index %r is less than '
138                                          'zero' % start,
139                                          contentRangeHdr) 
140            elif end >= self.fileSize:
141                # This is not an error -
142                # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35.1
143                log.warning('Range end index %r is greater than the length %r '
144                            'of the requested resource %r - reseting to %r',
145                            end, self.fileSize, filePath, self.fileSize - 1)
146                end = self.fileSize - 1
147               
148            # Set the total content length to return
149            self.contentLength = end + 1 - start
150            self.contentRange = (start, end)
151            self.contentRangeHdr = (
152                self.__class__.CONTENT_RANGE_FIELDNAME, 
153                self.__class__.CONTENT_RANGE_FORMAT_STR % 
154                                        (self.contentRange + (self.fileSize,))
155            )
156            self.fileObj.seek(start)
157        else:           
158            # Set the total content length to return
159            self.contentLength = self.fileSize
160                 
161        nReads = self.contentLength / self.blkSize
162        lastReadLen = self.contentLength % self.blkSize
163        self.readLengths = [self.blkSize] * nReads
164        if lastReadLen > 0:
165            nReads += 1
166            self.readLengths.append(lastReadLen)
167       
168    def __iter__(self):
169        '''Read the file a block at a time'''
170        while len(self.readLengths) > 0:
171            output = self.fileObj.read(self.readLengths[-1])
172            self.readLengths.pop()
173            yield output
174#        output = '\n'
175#        while len(output) is not 0:
176#            output = self.fileObj.read(self.end+1)
177#            yield output
178
179    def _getBlkSize(self):
180        return self.__blkSize
181   
182    def _setBlkSize(self, value):
183        self.__blkSize = int(value)
184        if self.__blkSize < 0:
185            raise ValueError('Expecting positive integer value for block size '
186                             'attribute')
187           
188    blkSize = property(fget=_getBlkSize, fset=_setBlkSize,
189                        doc="block size for reading the file in the iterator "
190                            "and returning a response")
191
192
193# Map HTTP status code to code + standard message string
194_statusCode2Msg = lambda code: "%d %s" % (code, 
195                    httplib.responses.get(code, httplib.INTERNAL_SERVER_ERROR))
196   
197
198class FileServerApp(object):
199    """Application to serve static content"""
200    PARAM_PREFIX = 'fileserver.'
201    FILE_SYS_ROOT_PATH_OPTNAME = 'fileSysRootPath'
202   
203    DEFAULT_READ_BLK_SIZE = 1024
204    DEFAULT_WRITE_BLK_SIZE = 1024
205    DEFAULT_CONTENT_TYPE = 'text/plain; charset=utf-8'
206    DEFAULT_MOUNT_POINT = ''
207   
208    statusCode2Msg = staticmethod(_statusCode2Msg)
209   
210    # Map HTTP status string to a given file system file access error
211    FILE_ACCESS_ERROR_MAP = {
212         errno.ENOENT: _statusCode2Msg(httplib.NOT_FOUND),
213         errno.EACCES: _statusCode2Msg(httplib.FORBIDDEN)
214    }
215   
216    __slots__ = (
217        '__readBlkSize',
218        '__writeBlkSize',
219        '__httpMethodMap',
220        '__fileSysRoot',
221        '__mountPoint',
222        '__mimeTypes',
223        '__fileFilterRegEx'
224    )
225   
226    def __init__(self, 
227                 fileSysRootPath, 
228                 mountPoint=DEFAULT_MOUNT_POINT,
229                 readBlkSize=DEFAULT_READ_BLK_SIZE,
230                 writeBlkSize=DEFAULT_WRITE_BLK_SIZE,
231                 fileFilterRegEx=None,
232                 mimeTypesFilePath=None):
233       
234        # Initialise all attributes here
235        self.__readBlkSize = None
236        self.__writeBlkSize = None
237        self.__httpMethodMap = None
238        self.__mountPoint = None
239        self.__fileSysRoot = None
240        self.__fileFilterRegEx = None
241       
242        # ... Then set default values here
243        self.fileSysRoot = os.path.abspath(
244                                os.path.expandvars(
245                                    os.path.expanduser(fileSysRootPath)
246                                    )
247                                )
248       
249        self.mountPoint = mountPoint
250       
251        # Set from property methods to apply validation - referencing
252        # self.__class__ means derived class could make an alternative setting
253       
254        # Block size for PUT or POST operations
255        self.readBlkSize = readBlkSize
256
257        # Block size for GET operation
258        self.writeBlkSize = writeBlkSize
259       
260        # Map HTTP method name to a method of this class
261        self.httpMethodMap = self.__class__.DEFAULT_HTTP_METHOD_MAP
262       
263        self.fileFilterRegEx = fileFilterRegEx
264       
265        # MIME types
266        self.mimeTypes = mimetypes.MimeTypes()
267       
268        if mimeTypesFilePath is not None:
269            self.mimeTypes.read(mimeTypesFilePath)
270
271    def _getFileFilterRegEx(self):
272        return self.__fileFilterRegEx
273
274    def _setFileFilterRegEx(self, value):
275        if value is None:
276            self.__fileFilterRegEx = value
277       
278        elif isinstance(value, basestring):
279            self.__fileFilterRegEx = re.compile(value)
280        else: 
281            raise TypeError('Expecting string or None type for '
282                            '"fileFilterRegEx" attribute; got %r' % type(value))
283       
284    fileFilterRegEx = property(_getFileFilterRegEx, _setFileFilterRegEx, 
285                               doc="Regular expression to filter out matching "
286                                   "files from being served")
287
288    def _getMimeTypes(self):
289        return self.__mimeTypes
290
291    def _setMimeTypes(self, value):
292        self.__mimeTypes = value
293
294    mimeTypes = property(_getMimeTypes, _setMimeTypes, 
295                         doc="Mime types object for determining response "
296                             "content type from file extension")
297
298    def _getFileSysRoot(self):
299        return self.__fileSysRoot
300
301    def _setFileSysRoot(self, value):
302        if not isinstance(value, basestring):
303            raise TypeError('Expecting string type for "fileSysRoot" '
304                            'attribute; got %r' % type(value))
305
306        self.__fileSysRoot = value
307
308    fileSysRoot = property(_getFileSysRoot, _setFileSysRoot, 
309                           doc="Root directory in file system from which to "
310                               "serve files")
311
312    def _getMountPoint(self):
313        return self.__mountPoint
314
315    def _setMountPoint(self, value):
316        if not isinstance(value, basestring):
317            raise TypeError('Expecting string type for "mountPoint" '
318                            'attribute; got %r' % type(value))
319           
320        self.__mountPoint = value
321
322    mountPoint = property(_getMountPoint, _setMountPoint, 
323                          doc="Mount point - root path for URI to serve files "
324                              "from")
325
326    def _getReadBlkSize(self):
327        return self.__readBlkSize
328
329    def _setReadBlkSize(self, value):
330        self.__readBlkSize = int(value)
331        if self.__readBlkSize < 0:
332            raise ValueError('Expecting positive integer value for block size '
333                             'attribute')
334
335    readBlkSize = property(_getReadBlkSize, _setReadBlkSize, 
336                           doc="Block size for read files for upload")
337
338    def _getWriteBlkSize(self):
339        return self.__writeBlkSize
340
341    def _setWriteBlkSize(self, value):
342        self.__writeBlkSize = int(value)
343        if self.__writeBlkSize < 0:
344            raise ValueError('Expecting positive integer value for block size '
345                             'attribute')
346       
347    writeBlkSize = property(_getWriteBlkSize, _setWriteBlkSize, 
348                            doc="Block size for writing files for download")
349
350    def _getHttpMethodMap(self):
351        return self.__httpMethodMap
352
353    def _setHttpMethodMap(self, value):
354        if not isinstance(value, dict):
355            raise TypeError('Expecting dict type for HTTP method map '
356                            'attribute; got %r' % type(value))
357           
358        for name, method in value.items():
359            if not isinstance(name, basestring):
360                raise TypeError('Expecting string type for HTTP method name; '
361                                'got %r' % type(name))
362                   
363            if not callable(method):
364                raise TypeError('Expecting callable for HTTP method ; got %r' % 
365                                type(method))
366                               
367        self.__httpMethodMap = value.copy()
368
369    httpMethodMap = property(_getHttpMethodMap, _setHttpMethodMap, 
370                             doc="Dictionary mapping HTTP method names to "
371                                 "methods of this class")
372
373    @classmethod
374    def app_factory(cls, global_conf, prefix=PARAM_PREFIX, **app_conf): 
375        """Function following Paste app factory signature
376       
377        @type global_conf: dict       
378        @param global_conf: PasteDeploy global configuration dictionary
379        @type prefix: basestring
380        @param prefix: prefix for configuration items
381        @type app_conf: dict       
382        @param app_conf: PasteDeploy application specific configuration
383        dictionary
384        """
385        # Filter based on prefix
386        if prefix:
387            prefixLen = len(prefix)
388            kw = dict([(k[prefixLen:], v) for k, v in app_conf.items() 
389                       if k.startswith(prefix)])
390           
391        # This app 
392        fileSysRootPath = kw.pop(cls.FILE_SYS_ROOT_PATH_OPTNAME)         
393        app = cls(fileSysRootPath, **kw)
394
395        return app
396   
397    def handler(self, environ, start_response):
398        """Translate URI path to local file system path and map correct
399        callback from the HTTP request method
400        """
401        if self.mountPoint:
402            splitPath = environ['PATH_INFO'].split(self.mountPoint, 1)
403            if len(splitPath) < 2:
404                log.error('Error splitting environ["PATH_INFO"]=%r with mount '
405                          'point %r: returning 404 Not Found response',
406                          environ['PATH_INFO'], self.mountPoint)
407               
408                status = FileServerApp.statusCode2Msg(httplib.NOT_FOUND)
409                start_response(status,
410                               [('Content-length', str(len(status))),
411                                ('Content-type', 'text/plain')])
412                return [status]
413             
414            relativeURI = splitPath[1]
415        else:
416            relativeURI = environ['PATH_INFO']
417           
418        fileSysSubDir = urllib.url2pathname(relativeURI)
419       
420        # This if statement stops os.path.join doing a join with an
421        # absolute path '/...'.  If this is done, the first argument is
422        # obliterated and the result is '/' exposing the root file system to
423        # the web client!!
424        if fileSysSubDir.startswith('/'):
425            fileSysSubDir = fileSysSubDir[1:]
426               
427       
428        requestMethodName = environ['REQUEST_METHOD']
429        requestMethod = self.httpMethodMap.get(requestMethodName)
430        if requestMethod is None:
431            response = ('%r HTTP request method is not supported' % 
432                        requestMethodName)
433            status = FileServerApp.statusCode2Msg(httplib.METHOD_NOT_ALLOWED)
434            start_response(status,
435                           [('Content-length', str(len(response))),
436                            ('Content-type', 'text/plain')])
437            return [response]
438           
439        return requestMethod(self, fileSysSubDir, environ, start_response)
440       
441    def do_get(self, fileSysSubDir, environ, start_response):
442        """HTTP GET callback"""
443        filePath = os.path.join(self.fileSysRoot, fileSysSubDir)
444       
445        scriptName = environ.get('SCRIPT_NAME', '')
446       
447        # Apply filter to filter out unwanted content
448        pat = self.fileFilterRegEx
449        if pat is not None:
450            _allowFile = lambda filename: pat.match(filename) is None
451        else:
452            _allowFile = lambda filename: True
453       
454        # Check for a HTTP Range request
455        requestRange = environ.get('HTTP_RANGE')
456       
457        if not _allowFile(fileSysSubDir):
458            status = FileServerApp.statusCode2Msg(httplib.NOT_FOUND)
459            response = status
460            start_response(status, 
461                           [('Content-Type', 'text/plain'),
462                            ('Content-length', str(len(response)))])
463            return [response]
464       
465        elif not os.path.exists(filePath):
466            response = status = FileServerApp.statusCode2Msg(httplib.NOT_FOUND)
467            log.error('Requesting URI path %r, corresponding file path %r '
468                      'doesn\'t exist: returning %s response',
469                      environ['PATH_INFO'],
470                      filePath,
471                      status)
472            start_response(status, 
473                           [('Content-Type', 'text/plain'),
474                            ('Content-Length', str(len(response)))])
475           
476            return [response]
477           
478        elif os.path.isdir(filePath):
479            dirContents = os.listdir(filePath)
480            mountPoint = self.mountPoint
481           
482            # Prepend '/' to pattern match test - it makes it easier to write
483            # a generic regular expression for filtering out both
484            # sub-directories - see above - and file names as here
485            lines = [
486                '<a href="%s%s/%s">%s</a>' % 
487                (scriptName,
488                 mountPoint,
489                 urllib.pathname2url(os.path.join(fileSysSubDir, filename)),
490                 filename) 
491                for filename in dirContents if _allowFile('/' + filename)
492            ]
493
494            response = ('<html><head/><body>%s</body></html>' % 
495                        '<br>'.join(lines))
496   
497            if requestRange is not None:
498                log.warning('Requested path %r will return a HTML type '
499                            'response listing directory content: ignoring HTTP '
500                            'range request %r', (environ['PATH_INFO'],
501                                                 requestRange))
502               
503            start_response(FileServerApp.statusCode2Msg(httplib.OK), 
504                           [('Cache-Control','no-cache'), 
505                            ('Pragma','no-cache'),
506                            ('Content-Type', 'text/html; charset=utf-8'),
507                            ('Content-Length', str(len(response)))])
508           
509            return [response]
510        else:
511            try:
512                fileResponse = FileResponse(filePath,
513                                            blkSize=self.writeBlkSize,
514                                            requestRange=requestRange)
515            except IOError, e:
516                # Map file access error to a HTTP response code
517                status = FileServerApp.mapFileAccessError2HttpStatus(e.errno)
518                response = status
519                start_response(status, 
520                               [('Content-Type', 'text/plain'),
521                                ('Content-length', str(len(response)))])
522                return [response]
523             
524            except InvalidRangeRequestSyntax, e:
525                # Byte range was requested for extraction but it was badly
526                # formatted
527                response = "%s\n" % e
528                log.error(response)
529                status = FileServerApp.statusCode2Msg(httplib.BAD_REQUEST)
530                start_response(status, 
531                               [('Content-Type', 'text/plain'),
532                                ('Content-length', str(len(response)))])
533                return [response]
534               
535            except InvalidRangeRequest, e:
536                # Byte range was requested for extraction but it's invalid for
537                # the requested file
538                response = "%s\n" % e
539                log.error(response)
540                status = FileServerApp.statusCode2Msg(
541                                        httplib.REQUESTED_RANGE_NOT_SATISFIABLE)
542                start_response(status, 
543                               [('Content-Type', 'text/plain'),
544                                ('Content-length', str(len(response))),
545                                 e.contentRangeHdr])
546                return [response]
547           
548            headers = [
549                ('Cache-Control','no-cache'), 
550                ('Pragma','no-cache'), 
551                ('Content-Length', str(fileResponse.contentLength)),
552                ('Content-Type', self.getContentType(environ['PATH_INFO']))
553            ]
554                                       
555            if requestRange:
556                status = FileServerApp.statusCode2Msg(httplib.PARTIAL_CONTENT)
557                headers.append(fileResponse.contentRangeHdr)
558            else:
559                status = FileServerApp.statusCode2Msg(httplib.OK)
560                               
561            start_response(status, headers)
562            return fileResponse
563       
564    def do_put(self, fileSysSubDir, environ, start_response):
565        """HTTP PUT callback"""
566        try:
567            filePath = os.path.join(self.fileSysRoot, fileSysSubDir)
568            fileObj = open(filePath, 'wb')
569            log.debug('opened file for writing %s', filePath)
570           
571        except IOError, e:
572            log.error('failed to open file for writing %r', filePath)
573            status = FileServerApp.mapFileAccessError2HttpStatus(e.errno)
574            response = status
575            start_response(status, 
576                           [('Content-Type', 'text/plain'),
577                            ('Content-length', str(len(response)))])
578            return [response]
579       
580        inputLength = environ['CONTENT_LENGTH']
581        inputStream = environ['wsgi.input']
582        nReads = inputLength / self.readBlkSize
583        remainder = inputLength % self.readBlkSize
584        readArray = [self.readBlkSize] * nReads
585        if remainder > 0:
586            nReads += 1
587            readArray.append(remainder)
588           
589        for length in readArray:
590            inputBlk = inputStream.read(length)
591            fileObj.write(inputBlk)
592
593    @classmethod
594    def mapFileAccessError2HttpStatus(cls, errno):
595        """Map file access error to a standard HTTP response status message"""
596        return cls.FILE_ACCESS_ERROR_MAP.get(errno, 
597                            cls.statusCode2Msg(httplib.INTERNAL_SERVER_ERROR))
598                                                       
599    def getContentType(self, path_info):
600        """Make a best guess at the content type"""
601        contentType = self.mimeTypes.guess_type(path_info)[0]
602        if contentType is not None:
603            return contentType
604        else:
605            return self.__class__.DEFAULT_CONTENT_TYPE
606           
607    def __call__(self, environ, start_response):
608        return self.handler(environ, start_response)
609       
610    DEFAULT_HTTP_METHOD_MAP = {
611        'GET':          do_get, 
612        'PUT':          do_put, 
613    }
Note: See TracBrowser for help on using the repository browser.