fileapp.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
  2. # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
  3. # (c) 2005 Ian Bicking, Clark C. Evans and contributors
  4. # This module is part of the Python Paste Project and is released under
  5. # the MIT License: http://www.opensource.org/licenses/mit-license.php
  6. """
  7. This module handles sending static content such as in-memory data or
  8. files. At this time it has cache helpers and understands the
  9. if-modified-since request header.
  10. """
  11. import os, time, mimetypes, zipfile, tarfile
  12. from paste.httpexceptions import *
  13. from paste.httpheaders import *
  14. CACHE_SIZE = 4096
  15. BLOCK_SIZE = 4096 * 16
  16. __all__ = ['DataApp', 'FileApp', 'DirectoryApp', 'ArchiveStore']
  17. class DataApp(object):
  18. """
  19. Returns an application that will send content in a single chunk,
  20. this application has support for setting cache-control and for
  21. responding to conditional (or HEAD) requests.
  22. Constructor Arguments:
  23. ``content`` the content being sent to the client
  24. ``headers`` the headers to send /w the response
  25. The remaining ``kwargs`` correspond to headers, where the
  26. underscore is replaced with a dash. These values are only
  27. added to the headers if they are not already provided; thus,
  28. they can be used for default values. Examples include, but
  29. are not limited to:
  30. ``content_type``
  31. ``content_encoding``
  32. ``content_location``
  33. ``cache_control()``
  34. This method provides validated construction of the ``Cache-Control``
  35. header as well as providing for automated filling out of the
  36. ``EXPIRES`` header for HTTP/1.0 clients.
  37. ``set_content()``
  38. This method provides a mechanism to set the content after the
  39. application has been constructed. This method does things
  40. like changing ``Last-Modified`` and ``Content-Length`` headers.
  41. """
  42. allowed_methods = ('GET', 'HEAD')
  43. def __init__(self, content, headers=None, allowed_methods=None,
  44. **kwargs):
  45. assert isinstance(headers, (type(None), list))
  46. self.expires = None
  47. self.content = None
  48. self.content_length = None
  49. self.last_modified = 0
  50. if allowed_methods is not None:
  51. self.allowed_methods = allowed_methods
  52. self.headers = headers or []
  53. for (k, v) in kwargs.items():
  54. header = get_header(k)
  55. header.update(self.headers, v)
  56. ACCEPT_RANGES.update(self.headers, bytes=True)
  57. if not CONTENT_TYPE(self.headers):
  58. CONTENT_TYPE.update(self.headers)
  59. if content is not None:
  60. self.set_content(content)
  61. def cache_control(self, **kwargs):
  62. self.expires = CACHE_CONTROL.apply(self.headers, **kwargs) or None
  63. return self
  64. def set_content(self, content, last_modified=None):
  65. assert content is not None
  66. if last_modified is None:
  67. self.last_modified = time.time()
  68. else:
  69. self.last_modified = last_modified
  70. self.content = content
  71. self.content_length = len(content)
  72. LAST_MODIFIED.update(self.headers, time=self.last_modified)
  73. return self
  74. def content_disposition(self, **kwargs):
  75. CONTENT_DISPOSITION.apply(self.headers, **kwargs)
  76. return self
  77. def __call__(self, environ, start_response):
  78. method = environ['REQUEST_METHOD'].upper()
  79. if method not in self.allowed_methods:
  80. exc = HTTPMethodNotAllowed(
  81. 'You cannot %s a file' % method,
  82. headers=[('Allow', ','.join(self.allowed_methods))])
  83. return exc(environ, start_response)
  84. return self.get(environ, start_response)
  85. def calculate_etag(self):
  86. return '"%s-%s"' % (self.last_modified, self.content_length)
  87. def get(self, environ, start_response):
  88. headers = self.headers[:]
  89. current_etag = self.calculate_etag()
  90. ETAG.update(headers, current_etag)
  91. if self.expires is not None:
  92. EXPIRES.update(headers, delta=self.expires)
  93. try:
  94. client_etags = IF_NONE_MATCH.parse(environ)
  95. if client_etags:
  96. for etag in client_etags:
  97. if etag == current_etag or etag == '*':
  98. # horribly inefficient, n^2 performance, yuck!
  99. for head in list_headers(entity=True):
  100. head.delete(headers)
  101. start_response('304 Not Modified', headers)
  102. return [b'']
  103. except HTTPBadRequest as exce:
  104. return exce.wsgi_application(environ, start_response)
  105. # If we get If-None-Match and If-Modified-Since, and
  106. # If-None-Match doesn't match, then we should not try to
  107. # figure out If-Modified-Since (which has 1-second granularity
  108. # and just isn't as accurate)
  109. if not client_etags:
  110. try:
  111. client_clock = IF_MODIFIED_SINCE.parse(environ)
  112. if (client_clock is not None
  113. and client_clock >= int(self.last_modified)):
  114. # horribly inefficient, n^2 performance, yuck!
  115. for head in list_headers(entity=True):
  116. head.delete(headers)
  117. start_response('304 Not Modified', headers)
  118. return [b''] # empty body
  119. except HTTPBadRequest as exce:
  120. return exce.wsgi_application(environ, start_response)
  121. (lower, upper) = (0, self.content_length - 1)
  122. range = RANGE.parse(environ)
  123. if range and 'bytes' == range[0] and 1 == len(range[1]):
  124. (lower, upper) = range[1][0]
  125. upper = upper or (self.content_length - 1)
  126. if upper >= self.content_length or lower > upper:
  127. return HTTPRequestRangeNotSatisfiable((
  128. "Range request was made beyond the end of the content,\r\n"
  129. "which is %s long.\r\n Range: %s\r\n") % (
  130. self.content_length, RANGE(environ))
  131. ).wsgi_application(environ, start_response)
  132. content_length = upper - lower + 1
  133. CONTENT_RANGE.update(headers, first_byte=lower, last_byte=upper,
  134. total_length = self.content_length)
  135. CONTENT_LENGTH.update(headers, content_length)
  136. if range or content_length != self.content_length:
  137. start_response('206 Partial Content', headers)
  138. else:
  139. start_response('200 OK', headers)
  140. if self.content is not None:
  141. return [self.content[lower:upper+1]]
  142. return (lower, content_length)
  143. class FileApp(DataApp):
  144. """
  145. Returns an application that will send the file at the given
  146. filename. Adds a mime type based on ``mimetypes.guess_type()``.
  147. See DataApp for the arguments beyond ``filename``.
  148. """
  149. def __init__(self, filename, headers=None, **kwargs):
  150. self.filename = filename
  151. content_type, content_encoding = self.guess_type()
  152. if content_type and 'content_type' not in kwargs:
  153. kwargs['content_type'] = content_type
  154. if content_encoding and 'content_encoding' not in kwargs:
  155. kwargs['content_encoding'] = content_encoding
  156. DataApp.__init__(self, None, headers, **kwargs)
  157. def guess_type(self):
  158. return mimetypes.guess_type(self.filename)
  159. def update(self, force=False):
  160. stat = os.stat(self.filename)
  161. if not force and stat.st_mtime == self.last_modified:
  162. return
  163. self.last_modified = stat.st_mtime
  164. if stat.st_size < CACHE_SIZE:
  165. fh = open(self.filename,"rb")
  166. self.set_content(fh.read(), stat.st_mtime)
  167. fh.close()
  168. else:
  169. self.content = None
  170. self.content_length = stat.st_size
  171. # This is updated automatically if self.set_content() is
  172. # called
  173. LAST_MODIFIED.update(self.headers, time=self.last_modified)
  174. def get(self, environ, start_response):
  175. is_head = environ['REQUEST_METHOD'].upper() == 'HEAD'
  176. if 'max-age=0' in CACHE_CONTROL(environ).lower():
  177. self.update(force=True) # RFC 2616 13.2.6
  178. else:
  179. self.update()
  180. if not self.content:
  181. if not os.path.exists(self.filename):
  182. exc = HTTPNotFound(
  183. 'The resource does not exist',
  184. comment="No file at %r" % self.filename)
  185. return exc(environ, start_response)
  186. try:
  187. file = open(self.filename, 'rb')
  188. except (IOError, OSError) as e:
  189. exc = HTTPForbidden(
  190. 'You are not permitted to view this file (%s)' % e)
  191. return exc.wsgi_application(
  192. environ, start_response)
  193. retval = DataApp.get(self, environ, start_response)
  194. if isinstance(retval, list):
  195. # cached content, exception, or not-modified
  196. if is_head:
  197. return [b'']
  198. return retval
  199. (lower, content_length) = retval
  200. if is_head:
  201. return [b'']
  202. file.seek(lower)
  203. file_wrapper = environ.get('wsgi.file_wrapper', None)
  204. if file_wrapper:
  205. return file_wrapper(file, BLOCK_SIZE)
  206. else:
  207. return _FileIter(file, size=content_length)
  208. class _FileIter(object):
  209. def __init__(self, file, block_size=None, size=None):
  210. self.file = file
  211. self.size = size
  212. self.block_size = block_size or BLOCK_SIZE
  213. def __iter__(self):
  214. return self
  215. def next(self):
  216. chunk_size = self.block_size
  217. if self.size is not None:
  218. if chunk_size > self.size:
  219. chunk_size = self.size
  220. self.size -= chunk_size
  221. data = self.file.read(chunk_size)
  222. if not data:
  223. raise StopIteration
  224. return data
  225. __next__ = next
  226. def close(self):
  227. self.file.close()
  228. class DirectoryApp(object):
  229. """
  230. Returns an application that dispatches requests to corresponding FileApps based on PATH_INFO.
  231. FileApp instances are cached. This app makes sure not to serve any files that are not in a subdirectory.
  232. To customize FileApp creation override ``DirectoryApp.make_fileapp``
  233. """
  234. def __init__(self, path):
  235. self.path = os.path.abspath(path)
  236. if not self.path.endswith(os.path.sep):
  237. self.path += os.path.sep
  238. assert os.path.isdir(self.path)
  239. self.cached_apps = {}
  240. make_fileapp = FileApp
  241. def __call__(self, environ, start_response):
  242. path_info = environ['PATH_INFO']
  243. app = self.cached_apps.get(path_info)
  244. if app is None:
  245. path = os.path.join(self.path, path_info.lstrip('/'))
  246. if not os.path.normpath(path).startswith(self.path):
  247. app = HTTPForbidden()
  248. elif os.path.isfile(path):
  249. app = self.make_fileapp(path)
  250. self.cached_apps[path_info] = app
  251. else:
  252. app = HTTPNotFound(comment=path)
  253. return app(environ, start_response)
  254. class ArchiveStore(object):
  255. """
  256. Returns an application that serves up a DataApp for items requested
  257. in a given zip or tar archive.
  258. Constructor Arguments:
  259. ``filepath`` the path to the archive being served
  260. ``cache_control()``
  261. This method provides validated construction of the ``Cache-Control``
  262. header as well as providing for automated filling out of the
  263. ``EXPIRES`` header for HTTP/1.0 clients.
  264. """
  265. def __init__(self, filepath):
  266. if zipfile.is_zipfile(filepath):
  267. self.archive = zipfile.ZipFile(filepath,"r")
  268. elif tarfile.is_tarfile(filepath):
  269. self.archive = tarfile.TarFileCompat(filepath,"r")
  270. else:
  271. raise AssertionError("filepath '%s' is not a zip or tar " % filepath)
  272. self.expires = None
  273. self.last_modified = time.time()
  274. self.cache = {}
  275. def cache_control(self, **kwargs):
  276. self.expires = CACHE_CONTROL.apply(self.headers, **kwargs) or None
  277. return self
  278. def __call__(self, environ, start_response):
  279. path = environ.get("PATH_INFO","")
  280. if path.startswith("/"):
  281. path = path[1:]
  282. application = self.cache.get(path)
  283. if application:
  284. return application(environ, start_response)
  285. try:
  286. info = self.archive.getinfo(path)
  287. except KeyError:
  288. exc = HTTPNotFound("The file requested, '%s', was not found." % path)
  289. return exc.wsgi_application(environ, start_response)
  290. if info.filename.endswith("/"):
  291. exc = HTTPNotFound("Path requested, '%s', is not a file." % path)
  292. return exc.wsgi_application(environ, start_response)
  293. content_type, content_encoding = mimetypes.guess_type(info.filename)
  294. # 'None' is not a valid content-encoding, so don't set the header if
  295. # mimetypes.guess_type returns None
  296. if content_encoding is not None:
  297. app = DataApp(None, content_type = content_type,
  298. content_encoding = content_encoding)
  299. else:
  300. app = DataApp(None, content_type = content_type)
  301. app.set_content(self.archive.read(path),
  302. time.mktime(info.date_time + (0,0,0)))
  303. self.cache[path] = app
  304. app.expires = self.expires
  305. return app(environ, start_response)