diff tincan.py @ 40:df27cf08c093 draft

Add support for serving static files.
author David Barts <n5jrn@me.com>
date Wed, 29 May 2019 09:40:32 -0700
parents ce67eac10fc7
children 9335865ae0bb
line wrap: on
line diff
--- a/tincan.py	Tue May 28 20:20:19 2019 -0700
+++ b/tincan.py	Wed May 29 09:40:32 2019 -0700
@@ -8,13 +8,16 @@
 import ast
 import binascii
 from base64 import b16encode, b16decode
+import email.utils
 import functools
 import importlib
 from inspect import isclass
 import io
+import mimetypes
 import py_compile
 from stat import S_ISDIR, S_ISREG
 from string import whitespace
+import time
 import traceback
 import urllib
 
@@ -378,6 +381,81 @@
 _FORIG = "tincan.origin"
 _FTYPE = "tincan.iserror"
 
+class _TinCanStaticRoute(object):
+    """
+    A route to a static file. These are useful for test servers. For
+    production servers, one is better off using a real web server and
+    a WSGI plugin, and having that handle static files. Much of this
+    logic is cribbed from the Bottle source code (we don't call it
+    directly because it is undocumented and thus subject to change).
+    """
+    def __init__(self, launcher, name, subdir):
+        self._app = launcher.app
+        self._fspath = os.path.join(launcher.fsroot, *subdir, name)
+        self._urlpath = self._urljoin(launcher.urlroot, *subdir, name)
+        self._type = mimetypes.guess_type(name)[0]
+        if self._type is None:
+            self._type = "application/octet-stream"
+        if self._type.startswith("text/"):
+            self._encoding = launcher.encoding
+            self._type += "; charset=" + launcher.encoding
+        else:
+            self._encoding = None
+
+    def launch(self):
+        print("adding static route:", self._urlpath)  # debug
+        self._app.route(self._urlpath, 'GET', self)
+
+    def _urljoin(self, *args):
+        args = list(args)
+        if args[0] == '/':
+            args[0] = ''
+        return '/'.join(args)
+
+    def _parse_date(self, ims):
+        """
+        Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch.
+        """
+        try:
+            ts = email.utils.parsedate_tz(ims)
+            return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone
+        except (TypeError, ValueError, IndexError, OverflowError):
+            return None
+
+    def __call__(self):
+        # Get file contents and time stamp. If we can't, return an
+        # appropriate HTTP error response.
+        try:
+            with open(self._fspath, "rb") as fp:
+                mtime = os.fstat(fp.fileno()).st_mtime
+                bytes = fp.read()
+        except FileNotFoundError as e:
+            print(e)  # debug
+            return bottle.HTTPError(status=404, exception=e)
+        except PermissionError as e:
+            print(e)  # debug
+            return bottle.HTTPError(status=403, exception=e)
+        except OSError as e:
+            print(e)  # debug
+            return bottle.HTTPError(status=500, exception=e)
+        # Establish preliminary standard headers.
+        headers = {
+            "Content-Type": self._type,
+            "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(mtime)),
+        }
+        if self._encoding:
+            headers["Content-Encoding"] = self._encoding
+        # Support the If-Modified-Since request header.
+        ims = bottle.request.environ.get('HTTP_IF_MODIFIED_SINCE')
+        if ims:
+            ims = self._parse_date(ims.split(";")[0].strip())
+        if ims is not None and ims >= int(stats.st_mtime):
+            headers["Content-Length"] = "0"
+            return bottle.HTTPResponse(body=b"", status=304, headers=headers)
+        # Standard response.
+        headers["Content-Length"] = str(len(bytes))
+        return bottle.HTTPResponse(body=bytes, status=200, headers=headers)
+
 class _TinCanErrorRoute(object):
     """
     A route to an error page. These don't get routes created for them,
@@ -425,7 +503,6 @@
         self._subdir = subdir
         self._seen = set()
         self._app = launcher.app
-        self._save_loads = launcher.debug
         self._encoding = launcher.encoding
 
     def launch(self):
@@ -679,6 +756,7 @@
 
 _WINF = "WEB-INF"
 _BANNED = set([_WINF])
+_EBANNED = set([_IEXTEN, _TEXTEN, _PEXTEN, _PEXTEN+"c"])
 ENCODING = "utf-8"
 
 class _Launcher(object):
@@ -696,6 +774,7 @@
         self.errors = 0
         self.debug = False
         self.encoding = ENCODING
+        self.static = False
 
     def launch(self):
         """
@@ -737,9 +816,15 @@
             etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode
             if S_ISREG(etype):
                 ename, eext = os.path.splitext(entry)
-                if eext != _TEXTEN:
-                    continue  # only look at interesting files
-                route = _TinCanRoute(self, ename, subdir)
+                if eext == _TEXTEN:
+                    route = _TinCanRoute(self, ename, subdir)
+                else:
+                    if eext in _EBANNED:
+                        continue
+                    if self.static:
+                        route = _TinCanStaticRoute(self, entry, subdir)
+                    else:
+                        continue
                 try:
                     route.launch()
                 except TinCanError as e:
@@ -756,7 +841,7 @@
     sys.stderr.write(message)
     sys.stderr.write('\n')
 
-def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING):
+def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING, static=False):
     """
     Launch and return a TinCan webapp. Does not run the app; it is the
     caller's responsibility to call app.run()
@@ -766,6 +851,7 @@
     launcher = _Launcher(fsroot, urlroot, logger)
     launcher.debug = debug
     launcher.encoding = encoding
+    launcher.static = static
     launcher.launch()
     return launcher.app, launcher.errors