Mercurial > cgi-bin > hgweb.cgi > tincan
comparison 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 |
comparison
equal
deleted
inserted
replaced
39:ee19984ba31d | 40:df27cf08c093 |
---|---|
6 | 6 |
7 import os, sys | 7 import os, sys |
8 import ast | 8 import ast |
9 import binascii | 9 import binascii |
10 from base64 import b16encode, b16decode | 10 from base64 import b16encode, b16decode |
11 import email.utils | |
11 import functools | 12 import functools |
12 import importlib | 13 import importlib |
13 from inspect import isclass | 14 from inspect import isclass |
14 import io | 15 import io |
16 import mimetypes | |
15 import py_compile | 17 import py_compile |
16 from stat import S_ISDIR, S_ISREG | 18 from stat import S_ISDIR, S_ISREG |
17 from string import whitespace | 19 from string import whitespace |
20 import time | |
18 import traceback | 21 import traceback |
19 import urllib | 22 import urllib |
20 | 23 |
21 import bottle | 24 import bottle |
22 | 25 |
376 _TEXTEN = ".pspx" | 379 _TEXTEN = ".pspx" |
377 _FLOOP = "tincan.forwards" | 380 _FLOOP = "tincan.forwards" |
378 _FORIG = "tincan.origin" | 381 _FORIG = "tincan.origin" |
379 _FTYPE = "tincan.iserror" | 382 _FTYPE = "tincan.iserror" |
380 | 383 |
384 class _TinCanStaticRoute(object): | |
385 """ | |
386 A route to a static file. These are useful for test servers. For | |
387 production servers, one is better off using a real web server and | |
388 a WSGI plugin, and having that handle static files. Much of this | |
389 logic is cribbed from the Bottle source code (we don't call it | |
390 directly because it is undocumented and thus subject to change). | |
391 """ | |
392 def __init__(self, launcher, name, subdir): | |
393 self._app = launcher.app | |
394 self._fspath = os.path.join(launcher.fsroot, *subdir, name) | |
395 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name) | |
396 self._type = mimetypes.guess_type(name)[0] | |
397 if self._type is None: | |
398 self._type = "application/octet-stream" | |
399 if self._type.startswith("text/"): | |
400 self._encoding = launcher.encoding | |
401 self._type += "; charset=" + launcher.encoding | |
402 else: | |
403 self._encoding = None | |
404 | |
405 def launch(self): | |
406 print("adding static route:", self._urlpath) # debug | |
407 self._app.route(self._urlpath, 'GET', self) | |
408 | |
409 def _urljoin(self, *args): | |
410 args = list(args) | |
411 if args[0] == '/': | |
412 args[0] = '' | |
413 return '/'.join(args) | |
414 | |
415 def _parse_date(self, ims): | |
416 """ | |
417 Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. | |
418 """ | |
419 try: | |
420 ts = email.utils.parsedate_tz(ims) | |
421 return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone | |
422 except (TypeError, ValueError, IndexError, OverflowError): | |
423 return None | |
424 | |
425 def __call__(self): | |
426 # Get file contents and time stamp. If we can't, return an | |
427 # appropriate HTTP error response. | |
428 try: | |
429 with open(self._fspath, "rb") as fp: | |
430 mtime = os.fstat(fp.fileno()).st_mtime | |
431 bytes = fp.read() | |
432 except FileNotFoundError as e: | |
433 print(e) # debug | |
434 return bottle.HTTPError(status=404, exception=e) | |
435 except PermissionError as e: | |
436 print(e) # debug | |
437 return bottle.HTTPError(status=403, exception=e) | |
438 except OSError as e: | |
439 print(e) # debug | |
440 return bottle.HTTPError(status=500, exception=e) | |
441 # Establish preliminary standard headers. | |
442 headers = { | |
443 "Content-Type": self._type, | |
444 "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(mtime)), | |
445 } | |
446 if self._encoding: | |
447 headers["Content-Encoding"] = self._encoding | |
448 # Support the If-Modified-Since request header. | |
449 ims = bottle.request.environ.get('HTTP_IF_MODIFIED_SINCE') | |
450 if ims: | |
451 ims = self._parse_date(ims.split(";")[0].strip()) | |
452 if ims is not None and ims >= int(stats.st_mtime): | |
453 headers["Content-Length"] = "0" | |
454 return bottle.HTTPResponse(body=b"", status=304, headers=headers) | |
455 # Standard response. | |
456 headers["Content-Length"] = str(len(bytes)) | |
457 return bottle.HTTPResponse(body=bytes, status=200, headers=headers) | |
458 | |
381 class _TinCanErrorRoute(object): | 459 class _TinCanErrorRoute(object): |
382 """ | 460 """ |
383 A route to an error page. These don't get routes created for them, | 461 A route to an error page. These don't get routes created for them, |
384 and are only reached if an error routes them there. Unless you create | 462 and are only reached if an error routes them there. Unless you create |
385 custom code-behind, only two variables are available to your template: | 463 custom code-behind, only two variables are available to your template: |
423 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + _TEXTEN) | 501 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + _TEXTEN) |
424 self._origin = self._urlpath | 502 self._origin = self._urlpath |
425 self._subdir = subdir | 503 self._subdir = subdir |
426 self._seen = set() | 504 self._seen = set() |
427 self._app = launcher.app | 505 self._app = launcher.app |
428 self._save_loads = launcher.debug | |
429 self._encoding = launcher.encoding | 506 self._encoding = launcher.encoding |
430 | 507 |
431 def launch(self): | 508 def launch(self): |
432 """ | 509 """ |
433 Launch a single page. | 510 Launch a single page. |
677 | 754 |
678 # L a u n c h e r | 755 # L a u n c h e r |
679 | 756 |
680 _WINF = "WEB-INF" | 757 _WINF = "WEB-INF" |
681 _BANNED = set([_WINF]) | 758 _BANNED = set([_WINF]) |
759 _EBANNED = set([_IEXTEN, _TEXTEN, _PEXTEN, _PEXTEN+"c"]) | |
682 ENCODING = "utf-8" | 760 ENCODING = "utf-8" |
683 | 761 |
684 class _Launcher(object): | 762 class _Launcher(object): |
685 """ | 763 """ |
686 Helper class for launching webapps. | 764 Helper class for launching webapps. |
694 self.logger = logger | 772 self.logger = logger |
695 self.app = None | 773 self.app = None |
696 self.errors = 0 | 774 self.errors = 0 |
697 self.debug = False | 775 self.debug = False |
698 self.encoding = ENCODING | 776 self.encoding = ENCODING |
777 self.static = False | |
699 | 778 |
700 def launch(self): | 779 def launch(self): |
701 """ | 780 """ |
702 Does the actual work of launching something. XXX - modifies sys.path | 781 Does the actual work of launching something. XXX - modifies sys.path |
703 and never un-modifies it. | 782 and never un-modifies it. |
735 if not subdir and entry in _BANNED: | 814 if not subdir and entry in _BANNED: |
736 continue | 815 continue |
737 etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode | 816 etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode |
738 if S_ISREG(etype): | 817 if S_ISREG(etype): |
739 ename, eext = os.path.splitext(entry) | 818 ename, eext = os.path.splitext(entry) |
740 if eext != _TEXTEN: | 819 if eext == _TEXTEN: |
741 continue # only look at interesting files | 820 route = _TinCanRoute(self, ename, subdir) |
742 route = _TinCanRoute(self, ename, subdir) | 821 else: |
822 if eext in _EBANNED: | |
823 continue | |
824 if self.static: | |
825 route = _TinCanStaticRoute(self, entry, subdir) | |
826 else: | |
827 continue | |
743 try: | 828 try: |
744 route.launch() | 829 route.launch() |
745 except TinCanError as e: | 830 except TinCanError as e: |
746 self.logger(str(e)) | 831 self.logger(str(e)) |
747 if self.debug: | 832 if self.debug: |
754 | 839 |
755 def _logger(message): | 840 def _logger(message): |
756 sys.stderr.write(message) | 841 sys.stderr.write(message) |
757 sys.stderr.write('\n') | 842 sys.stderr.write('\n') |
758 | 843 |
759 def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING): | 844 def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING, static=False): |
760 """ | 845 """ |
761 Launch and return a TinCan webapp. Does not run the app; it is the | 846 Launch and return a TinCan webapp. Does not run the app; it is the |
762 caller's responsibility to call app.run() | 847 caller's responsibility to call app.run() |
763 """ | 848 """ |
764 if fsroot is None: | 849 if fsroot is None: |
765 fsroot = os.getcwd() | 850 fsroot = os.getcwd() |
766 launcher = _Launcher(fsroot, urlroot, logger) | 851 launcher = _Launcher(fsroot, urlroot, logger) |
767 launcher.debug = debug | 852 launcher.debug = debug |
768 launcher.encoding = encoding | 853 launcher.encoding = encoding |
854 launcher.static = static | |
769 launcher.launch() | 855 launcher.launch() |
770 return launcher.app, launcher.errors | 856 return launcher.app, launcher.errors |
771 | 857 |
772 # XXX - We cannot implement a command-line launcher here; see the | 858 # XXX - We cannot implement a command-line launcher here; see the |
773 # launcher script for why. | 859 # launcher script for why. |