chiark / gitweb /
02797cec108ea59458e51fca804d3b96a1d363bf
[chopwood] / cgi.py
1 ### -*-python-*-
2 ###
3 ### CGI machinery
4 ###
5 ### (c) 2013 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Chopwood: a password-changing service.
11 ###
12 ### Chopwood is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU Affero General Public License as
14 ### published by the Free Software Foundation; either version 3 of the
15 ### License, or (at your option) any later version.
16 ###
17 ### Chopwood is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU Affero General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU Affero General Public
23 ### License along with Chopwood; if not, see
24 ### <http://www.gnu.org/licenses/>.
25
26 from __future__ import with_statement
27
28 import contextlib as CTX
29 import os as OS; ENV = OS.environ
30 import re as RX
31 import sys as SYS
32 import time as T
33 import traceback as TB
34
35 from auto import HOME, PACKAGE, VERSION
36 import config as CONF; CFG = CONF.CFG
37 import format as F
38 import output as O; OUT = O.OUT; PRINT = O.PRINT
39 import subcommand as SC
40 import util as U
41
42 ###--------------------------------------------------------------------------
43 ### Configuration tweaks.
44
45 _script_name = ENV.get('SCRIPT_NAME', '/cgi-bin/chpwd')
46
47 CONF.DEFAULTS.update(
48
49   ## The URL of this program, when it's run through CGI.
50   SCRIPT_NAME = _script_name,
51
52   ## A (maybe relative) URL for static content.  By default this comes from
53   ## the main script, but we hope that user agents cache it.
54   STATIC = _script_name + '/static')
55
56 ###--------------------------------------------------------------------------
57 ### Escaping and encoding.
58
59 ## Some handy regular expressions.
60 R_URLESC = RX.compile('%([0-9a-fA-F]{2})')
61 R_URLBAD = RX.compile('[^-\\w,.!]')
62 R_HTMLBAD = RX.compile('[&<>]')
63
64 def urldecode(s):
65   """Decode a single form-url-encoded string S."""
66   return R_URLESC.sub(lambda m: chr(int(m.group(1), 16)),
67                       s.replace('+', ' '))
68   return s
69
70 def urlencode(s):
71   """Encode a single string S using form-url-encoding."""
72   return R_URLBAD.sub(lambda m: '%%%02x' % ord(m.group(0)), s)
73
74 def htmlescape(s):
75   """Escape a literal string S so that HTML doesn't misinterpret it."""
76   return R_HTMLBAD.sub(lambda m: '&#x%02x;' % ord(m.group(0)), s)
77
78 ## Some standard character sequences, and HTML entity names for prettier
79 ## versions.
80 _quotify = U.StringSubst({
81   "`": '&lsquo;',
82   "'": '&rsquo;',
83   "``": '&ldquo;',
84   "''": '&rdquo;',
85   "--": '&ndash;',
86   "---": '&mdash;'
87 })
88 def html_quotify(s):
89   """Return a pretty HTML version of S."""
90   return _quotify(htmlescape(s))
91
92 ###--------------------------------------------------------------------------
93 ### Output machinery.
94
95 class HTTPOutput (O.FileOutput):
96   """
97   Output driver providing an automatic HTTP header.
98
99   The `headerp' attribute is true if we've written a header.  The `header'
100   method will print a custom header if this is wanted.
101   """
102
103   def __init__(me, *args, **kw):
104     """Constructor: initialize `headerp' flag."""
105     super(HTTPOutput, me).__init__(*args, **kw)
106     me.headerp = False
107
108   def write(me, msg):
109     """Output protocol: print a header if we've not written one already."""
110     if not me.headerp: me.header('text/plain')
111     super(HTTPOutput, me).write(msg)
112
113   def header(me, content_type = 'text/plain', **kw):
114     """
115     Print a header, if none has yet been printed.
116
117     Keyword arguments can be passed to emit HTTP headers: see `http_header'
118     for the formatting rules.
119     """
120     if me.headerp: return
121     me.headerp = True
122     for h in O.http_headers(content_type = content_type, **kw):
123       me.writeln(h)
124     me.writeln('')
125
126 def cookie(name, value, **kw):
127   """
128   Return a HTTP `Set-Cookie' header.
129
130   The NAME and VALUE give the name and value of the cookie; both are
131   form-url-encoded to prevent misinterpretation (fortunately, `cgiparse'
132   knows to undo this transformation).  The KW are other attributes to
133   declare: the names are forced to lower-case and underscores `_' are
134   replaced by hyphens `-'; a `True' value is assumed to indicate that the
135   attribute is boolean, and omitted.
136   """
137   attr = {}
138   for k, v in kw.iteritems():
139     k = '-'.join(i.lower() for i in k.split('_'))
140     attr[k] = v
141   try: maxage = int(attr['max-age'])
142   except KeyError: pass
143   else:
144     attr['expires'] = T.strftime('%a, %d %b %Y %H:%M:%S GMT',
145                                  T.gmtime(U.NOW + maxage))
146   return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
147                    [v is not True and '%s=%s' % (k, v) or k
148                     for k, v in attr.iteritems()])
149
150 def action(*v, **kw):
151   """
152   Build a URL invoking this script.
153
154   The positional arguments V are used to construct a path which is appended
155   to the (deduced or configured) script name (and presumably will be read
156   back as `PATH_INFO').  The keyword arguments are (form-url-encoded and)
157   appended as a query string, if present.
158   """
159   url = '/'.join([CFG.SCRIPT_NAME] + list(v))
160   if kw:
161     url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
162                           for k in sorted(kw))
163   return htmlescape(url)
164
165 def static(name):
166   """Build a URL for the static file NAME."""
167   return htmlescape(CFG.STATIC + '/' + name)
168
169 @CTX.contextmanager
170 def html(title, **kw):
171   """
172   Context manager for HTML output.
173
174   Keyword arguments are output as HTTP headers (if no header has been written
175   yet).  A `<head>' element is written, and a `<body>' opened, before the
176   context body is executed; the elements are closed off properly at the end.
177   """
178
179   kw = dict(kw, content_type = 'text/html')
180   OUT.header(**kw)
181
182   ## Write the HTML header.
183   PRINT("""\
184 <!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
185           'http://www.w3c.org/TR/html4/strict.dtd'>
186 <html>
187 <head>
188   <title>%(title)s</title>
189   <link rel=stylesheet type='text/css' media=screen href='%(style)s'>
190   <meta http-equiv='Content-Script-Type' content='text/javascript'>
191   <script type='text/javascript' src='%(script)s'></script>
192 </head>""" % dict(title = html_quotify(title),
193                  style = static('chpwd.css'),
194                  script = static('chpwd.js')))
195
196   ## Write the body.
197   PRINT('<body>')
198   yield None
199   PRINT('''\
200
201 <div class=credits>
202   <a href="%(about)s">Chopwood</a>, version %(version)s:
203   copyright &copy; 2012 Mark Wooding
204 </div>
205
206 </body>
207 </html>''' % dict(about = static('about.html'),
208                   version = VERSION))
209
210 def redirect(where, **kw):
211   """
212   Write a complete redirection to some other URL.
213   """
214   OUT.header(content_type = 'text/html',
215              status = 302, location = where,
216              **kw)
217   PRINT("""\
218 <html>
219 <head><title>No, sorry, it's moved again.</title></head>
220 <body><p>I'm <a href="%s">over here</a> now.<body>
221 </html>""" % htmlescape(where))
222
223 ###--------------------------------------------------------------------------
224 ### Templates.
225
226 ## Where we find our templates.
227 TMPLDIR = HOME
228
229 ## Keyword arguments for templates.
230 STATE = U.Fluid()
231 STATE.kw = {}
232
233 ## Set some basic keyword arguments.
234 @CONF.hook
235 def set_template_keywords():
236   STATE.kw.update(
237     package = PACKAGE,
238     version = VERSION,
239     script = CFG.SCRIPT_NAME,
240     static = CFG.STATIC)
241
242 class TemplateFinder (object):
243   """
244   A magical fake dictionary whose keys are templates.
245   """
246   def __init__(me, dir):
247     me._cache = {}
248     me._dir = dir
249   def __getitem__(me, key):
250     try: return me._cache[key]
251     except KeyError: pass
252     with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
253     me._cache[key] = tmpl
254     return tmpl
255 TMPL = TemplateFinder(TMPLDIR)
256
257 @CTX.contextmanager
258 def tmplkw(**kw):
259   """
260   Context manager: execute the body with additional keyword arguments
261   """
262   d = dict()
263   d.update(STATE.kw)
264   d.update(kw)
265   with STATE.bind(kw = d): yield
266
267 FORMATOPS = {}
268
269 class FormatHTML (F.SimpleFormatOperation):
270   """
271   ~H: escape output suitable for inclusion in HTML.
272
273   With `:', instead apply form-urlencoding.
274   """
275   def _convert(me, arg):
276     if me.colonp: return html_quotify(arg)
277     else: return htmlescape(arg)
278 FORMATOPS['H'] = FormatHTML
279
280 def format_tmpl(control, **kw):
281   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
282     with tmplkw(**kw):
283       F.format(OUT, control, **STATE.kw)
284
285 def page(template, header = {}, title = 'Chopwood', **kw):
286   header = dict(header, content_type = 'text/html')
287   OUT.header(**header)
288   format_tmpl(TMPL['wrapper.fhtml'],
289               title = title, payload = TMPL[template], **kw)
290
291 ###--------------------------------------------------------------------------
292 ### Error reporting.
293
294 @CTX.contextmanager
295 def cgi_errors(hook = None):
296   """
297   Context manager: report errors in the body as useful HTML.
298
299   If HOOK is given, then call it before reporting errors.  It may have set up
300   useful stuff.
301   """
302   try:
303     yield None
304   except Exception, e:
305     if hook: hook()
306     if isinstance(e, U.ExpectedError) and not OUT.headerp:
307       page('error.fhtml',
308            headers = dict(status = e.code),
309            title = 'Chopwood: error', error = e)
310     else:
311       exty, exval, extb = SYS.exc_info()
312       with tmplkw(exception = TB.format_exception_only(exty, exval),
313                   traceback = TB.extract_tb(extb),
314                   PARAM = sorted(PARAM),
315                   COOKIE = sorted(COOKIE.items()),
316                   PATH = PATH,
317                   ENV = sorted(ENV.items())):
318         if OUT.headerp:
319           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
320         else:
321           page('exception.fhtml',
322                headers = dict(status = 500),
323                title = 'Chopwood: internal error',
324                toplevel = True)
325
326 ###--------------------------------------------------------------------------
327 ### CGI input.
328
329 ## Lots of global variables to be filled in by `cgiparse'.
330 COOKIE = {}
331 SPECIAL = {}
332 PARAM = []
333 PARAMDICT = {}
334 PATH = []
335
336 ## Regular expressions for splitting apart query and cookie strings.
337 R_QSPLIT = RX.compile('[;&]')
338 R_CSPLIT = RX.compile(';')
339
340 def split_keyvalue(string, delim, default):
341   """
342   Split a STRING, and generate the resulting KEY=VALUE pairs.
343
344   The string is split at DELIM; the components are parsed into KEY[=VALUE]
345   pairs.  The KEYs and VALUEs are stripped of leading and trailing
346   whitespace, and form-url-decoded.  If the VALUE is omitted, then the
347   DEFAULT is used unless the DEFAULT is `None' in which case the component is
348   simply ignored.
349   """
350   for kv in delim.split(string):
351     try:
352       k, v = kv.split('=', 1)
353     except ValueError:
354       if default is None: continue
355       else: k, v = kv, default
356     k, v = k.strip(), v.strip()
357     if not k: continue
358     k, v = urldecode(k), urldecode(v)
359     yield k, v
360
361 def cgiparse():
362   """
363   Process all of the various exciting CGI environment variables.
364
365   We read environment variables and populate some tables left in global
366   variables: it's all rather old-school.  Variables set are as follows.
367
368   `COOKIE'
369         A dictionary mapping cookie names to the values provided by the user
370         agent.
371
372   `SPECIAL'
373         A dictionary holding some special query parameters which are of
374         interest at a global level, and should not be passed to a subcommand
375         handler.  No new entries will be added to this dictionary, though
376         values will be modified to reflect the query parameters discovered.
377         Conventionally, such parameters have names beginning with `%'.
378
379   `PARAM'
380         The query parameters as a list of (KEY, VALUE) pairs.  Special
381         parameters are omitted.
382
383   `PARAMDICT'
384         The query parameters as a dictionary.  Special parameters, and
385         parameters which appear more than once, are omitted.
386
387   `PATH'
388         The trailing `PATH_INFO' path, split at `/' markers, with any
389         trailing empty component removed.
390   """
391
392   def getenv(var):
393     try: return ENV[var]
394     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
395
396   ## Yes, we want the request method.
397   method = getenv('REQUEST_METHOD')
398
399   ## Acquire the query string.
400   if method == 'GET':
401     q = getenv('QUERY_STRING')
402
403   elif method == 'POST':
404
405     ## We must read the query string from stdin.
406     n = getenv('CONTENT_LENGTH')
407     if not n.isdigit():
408       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
409     n = int(n, 10)
410     if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
411       raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
412     q = SYS.stdin.read(n)
413     if len(q) != n:
414       raise U.ExpectedError, (500, "Failed to read correct length")
415
416   else:
417     raise U.ExpectedError, (500, "Unexpected request method `%s'" % method)
418
419   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
420   seen = set()
421   for k, v in split_keyvalue(q, R_QSPLIT, 't'):
422     if k in SPECIAL:
423       SPECIAL[k] = v
424     else:
425       PARAM.append((k, v))
426       if k in seen:
427         del PARAMDICT[k]
428       else:
429         PARAMDICT[k] = v
430         seen.add(k)
431
432   ## Parse out the cookies, if any.
433   try: c = ENV['HTTP_COOKIE']
434   except KeyError: pass
435   else:
436     for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
437
438   ## Set up the `PATH'.
439   try: p = ENV['PATH_INFO']
440   except KeyError: pass
441   else:
442     pp = p.lstrip('/').split('/')
443     if pp and not pp[-1]: pp.pop()
444     PATH[:] = pp
445
446 ###--------------------------------------------------------------------------
447 ### CGI subcommands.
448
449 class Subcommand (SC.Subcommand):
450   """
451   A CGI subcommand object.
452
453   As for `subcommand.Subcommand', but with additional protocol for processing
454   CGI parameters.
455   """
456
457   def cgi(me, param, path):
458     """
459     Invoke the subcommand given a collection of CGI parameters.
460
461     PARAM is a list of (KEY, VALUE) pairs from the CGI query.  The CGI query
462     parameters are checked against the subcommand's parameters (making sure
463     that mandatory parameters are supplied, that any switches are given
464     boolean values, and that only the `rest' parameter, if any, is
465     duplicated).
466
467     PATH is a list of trailing path components.  They are used to satisfy the
468     `rest' parameter if there is one and there are no query parameters which
469     satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
470     the list of path elements is non-empty.
471     """
472
473     ## We're going to make a pass over the supplied parameters, and we'll
474     ## check them off against the formal parameters as we go; so we'll need
475     ## to be able to look them up.  We'll also keep track of the ones we've
476     ## seen so that we can make sure that all of the mandatory parameters
477     ## were actually supplied.
478     ##
479     ## To that end: `want' is a dictionary mapping parameter names to
480     ## functions which will do something useful with the value; `seen' is a
481     ## set of the parameters which have been assigned; and `kw' is going to
482     ## be the keyword-argument dictionary we pass to the handler function.
483     want = {}
484     kw = {}
485
486     def set_value(k, v):
487       """Set a simple value: we shouldn't see multiple values."""
488       if k in kw:
489         raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
490       kw[k] = v
491     def set_bool(k, v):
492       """Set a simple boolean value: for switches."""
493       set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
494     def set_list(k, v):
495       """Append the value to a list: for the `rest' parameter."""
496       kw.setdefault(k, []).append(v)
497
498     ## Set up the `want' map.
499     for o in me.opts:
500       if o.argname: want[o.name] = set_value
501       else: want[o.name] = set_bool
502     for p in me.params: want[p.name] = set_value
503     for p in me.oparams: want[p.name] = set_value
504     if me.rparam: want[me.rparam.name] = set_list
505
506     ## Work through the list of supplied parameters.
507     for k, v in param:
508       try:
509         f = want[k]
510       except KeyError:
511         if v:
512           raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
513       else:
514         f(k, v)
515
516     ## Deal with a path, if there is one.
517     if path:
518       if me.rparam and me.rparam.name not in kw:
519         kw[me.rparam.name] = path
520       else:
521         raise U.ExpectedError, (404, "Superfluous path elements")
522
523     ## Make sure we saw all of the mandatory parameters.
524     for p in me.params:
525       if p.name not in kw:
526         raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
527
528     ## Invoke the subcommand.
529     me.func(**kw)
530
531 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
532   """Decorator for defining CGI subcommands."""
533   return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw)
534
535 ###----- That's all, folks --------------------------------------------------