| 1 | """Module for handling server-side-include formatted files |
| 2 | |
| 3 | This module is quite incomplete. I might complete it with more |
| 4 | features as I need them. It will probably never be entirely compliant |
| 5 | with Apache's version due to architectural differences. |
| 6 | """ |
| 7 | |
| 8 | import sys, os, io, time, logging, functools |
| 9 | from . import wsgiutil |
| 10 | |
| 11 | log = logging.getLogger("ssi") |
| 12 | |
| 13 | def parsecmd(text, p): |
| 14 | try: |
| 15 | while text[p].isspace(): p += 1 |
| 16 | cmd = "" |
| 17 | while not text[p].isspace(): |
| 18 | cmd += text[p] |
| 19 | p += 1 |
| 20 | pars = {} |
| 21 | while True: |
| 22 | while text[p].isspace(): p += 1 |
| 23 | if text[p:p + 3] == "-->": |
| 24 | return cmd, pars, p + 3 |
| 25 | key = "" |
| 26 | while text[p].isalnum(): |
| 27 | key += text[p] |
| 28 | p += 1 |
| 29 | if key == "": |
| 30 | return None, {}, p |
| 31 | while text[p].isspace(): p += 1 |
| 32 | if text[p] != '=': |
| 33 | continue |
| 34 | p += 1 |
| 35 | while text[p].isspace(): p += 1 |
| 36 | q = text[p] |
| 37 | if q != '"' and q != "'" and q != '`': |
| 38 | continue |
| 39 | val = "" |
| 40 | p += 1 |
| 41 | while text[p] != q: |
| 42 | val += text[p] |
| 43 | p += 1 |
| 44 | p += 1 |
| 45 | pars[key] = val |
| 46 | except IndexError: |
| 47 | return None, {}, len(text) |
| 48 | |
| 49 | class context(object): |
| 50 | def __init__(self, out, root): |
| 51 | self.out = out |
| 52 | self.vars = {} |
| 53 | now = time.time() |
| 54 | self.vars["DOCUMENT_NAME"] = os.path.basename(root.path) |
| 55 | self.vars["DATE_GMT"] = time.asctime(time.gmtime(now)) |
| 56 | self.vars["DATE_LOCAL"] = time.asctime(time.localtime(now)) |
| 57 | self.vars["LAST_MODIFIED"] = time.asctime(time.localtime(root.mtime)) |
| 58 | |
| 59 | class ssifile(object): |
| 60 | def __init__(self, path): |
| 61 | self.path = path |
| 62 | sb = os.stat(self.path) |
| 63 | self.cache = (sb.st_mode & 0o010) != 0 |
| 64 | self.mtime = int(sb.st_mtime) |
| 65 | with open(path) as fp: |
| 66 | self.parts = self.parse(fp.read()) |
| 67 | |
| 68 | def text(self, text, ctx): |
| 69 | ctx.out.write(text) |
| 70 | |
| 71 | def echo(self, var, enc, ctx): |
| 72 | if var in ctx.vars: |
| 73 | ctx.out.write(enc(ctx.vars[var])) |
| 74 | |
| 75 | def include(self, path, ctx): |
| 76 | try: |
| 77 | nest = getfile(os.path.join(os.path.dirname(self.path), path)) |
| 78 | except Exception: |
| 79 | log.warning("%s: could not find included file %s" % (self.path, path)) |
| 80 | return |
| 81 | nest.process(ctx) |
| 82 | |
| 83 | def process(self, ctx): |
| 84 | for part in self.parts: |
| 85 | part(ctx) |
| 86 | |
| 87 | def resolvecmd(self, cmd, pars): |
| 88 | if cmd == "include": |
| 89 | if "file" in pars: |
| 90 | return functools.partial(self.include, pars["file"]) |
| 91 | elif "virtual" in pars: |
| 92 | # XXX: For now, just include the file as-is. Change |
| 93 | # when necessary. |
| 94 | return functools.partial(self.include, pars["virtual"]) |
| 95 | else: |
| 96 | log.warning("%s: invalid `include' directive" % self.path) |
| 97 | return None |
| 98 | elif cmd == "echo": |
| 99 | if not "var" in pars: |
| 100 | log.warning("%s: invalid `echo' directive" % self.path) |
| 101 | return None |
| 102 | enc = wsgiutil.htmlquote |
| 103 | if "encoding" in pars: |
| 104 | if pars["encoding"] == "entity": |
| 105 | enc = wsgiutil.htmlquote |
| 106 | return functools.partial(self.echo, pars["var"], enc) |
| 107 | else: |
| 108 | log.warning("%s: unknown SSI command `%s'" % (self.path, cmd)) |
| 109 | return None |
| 110 | |
| 111 | def parse(self, text): |
| 112 | ret = [] |
| 113 | p = 0 |
| 114 | while True: |
| 115 | p2 = text.find("<!--#", p) |
| 116 | if p2 < 0: |
| 117 | ret.append(functools.partial(self.text, text[p:])) |
| 118 | return ret |
| 119 | ret.append(functools.partial(self.text, text[p:p2])) |
| 120 | cmd, pars, p = parsecmd(text, p2 + 5) |
| 121 | if cmd is not None: |
| 122 | cmd = self.resolvecmd(cmd, pars) |
| 123 | if cmd is not None: |
| 124 | ret.append(cmd) |
| 125 | |
| 126 | filecache = {} |
| 127 | |
| 128 | def getfile(path): |
| 129 | path = os.path.normpath(path) |
| 130 | cf = filecache.get(path) |
| 131 | if not cf: |
| 132 | cf = filecache[path] = ssifile(path) |
| 133 | elif int(os.stat(path).st_mtime) != cf.mtime: |
| 134 | cf = filecache[path] = ssifile(path) |
| 135 | return cf |
| 136 | |
| 137 | def wsgi(env, startreq): |
| 138 | try: |
| 139 | if env["PATH_INFO"] != "": |
| 140 | return wsgiutil.simpleerror(env, startreq, 404, "Not Found", "The resource specified by the URL does not exist.") |
| 141 | root = getfile(env["SCRIPT_FILENAME"]) |
| 142 | |
| 143 | if root.cache and "HTTP_IF_MODIFIED_SINCE" in env: |
| 144 | try: |
| 145 | lmt = wsgiutil.phttpdate(env["HTTP_IF_MODIFIED_SINCE"]) |
| 146 | if root.mtime <= lmt: |
| 147 | startreq("304 Not Modified", [("Content-Length", "0")]) |
| 148 | return [] |
| 149 | except: |
| 150 | pass |
| 151 | |
| 152 | buf = io.StringIO() |
| 153 | root.process(context(buf, root)) |
| 154 | except Exception: |
| 155 | return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server encountered an unpexpected error while handling SSI.") |
| 156 | ret = buf.getvalue().encode("utf8") |
| 157 | head = [("Content-Type", "text/html; charset=UTF-8"), ("Content-Length", str(len(ret)))] |
| 158 | if root.cache: |
| 159 | head.append(("Last-Modified", wsgiutil.httpdate(root.mtime))) |
| 160 | startreq("200 OK", head) |
| 161 | return [ret] |