python: Added XBitHack-style caching to SSI handler.
[ashd.git] / python3 / ashd / ssi.py
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]