Initial commit.
[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 def cgi_error_guts():
295 """
296 Report an exception while we're acting as a CGI, together with lots of
297 information about our state.
298
299 Our caller has, probably at great expense, arranged that we can format lots
300 of text.
301 """
302
303 ## Grab the exception information.
304 exty, exval, extb = SYS.exc_info()
305
306 ## Print the exception itself.
307 PRINT("""\
308 <h2>Exception</h2>
309 <pre>%s</pre>""" % html_quotify(
310 '\n'.join(TB.format_exception_only(exty, exval))))
311
312 ## Format a traceback so we can find out what has gone wrong.
313 PRINT("""\
314 <h2>Traceback</h2>
315 <ol>""")
316 for file, line, func, text in TB.extract_tb(extb, 20):
317 PRINT("<li><b>%s</b>:%d (<b>%s</b>)" % (
318 htmlescape(file), line, htmlescape(func)))
319 if text is not None:
320 PRINT("<br><tt>%s</tt>" % htmlescape(text))
321 PRINT("</ol>")
322
323 ## Format various useful tables.
324 def fmt_dict(d):
325 fmt_kvlist(d.iteritems())
326 def fmt_kvlist(l):
327 for k, v in sorted(l):
328 PRINT("<tr><th align=right valign=top>%s<td><tt>%s</tt>" % (
329 htmlescape(k), htmlescape(v)))
330 def fmt_list(l):
331 for i in l:
332 PRINT("<tr><tt>%s</tt>" % htmlescape(i))
333
334 PRINT("""\
335 <h2>Parameters</h2>""")
336 for what, thing, how in [('Query', PARAM, fmt_kvlist),
337 ('Cookies', COOKIE, fmt_dict),
338 ('Path', PATH, fmt_list),
339 ('Environment', ENV, fmt_dict)]:
340 PRINT("<h3>%s</h3>\n<table>" % what)
341 how(thing)
342 PRINT("</table>")
343
344 def cgi_error():
345 """
346 Report an exception while in CGI mode.
347
348 If we've not produced a header yet, then we can do that, and produce a
349 status code and everything; otherwise we'll have to make do with a small
350 piece of the page.
351 """
352 if OUT.headerp:
353 PRINT("<div class=exception>")
354 cgi_error_guts()
355 PRINT("</div>\n</body></html>")
356 else:
357 with html("chpwd internal error", status = 500):
358 PRINT("<h1>chpwd internal error</h1>")
359 cgi_error_guts()
360 SYS.exit(1)
361
362 @CTX.contextmanager
363 def cgi_errors(hook = None):
364 """
365 Context manager: report errors in the body as useful HTML.
366
367 If HOOK is given, then call it before reporting errors. It may have set up
368 useful stuff.
369 """
370 try:
371 yield None
372 except Exception, e:
373 if hook: hook()
374 if isinstance(e, U.ExpectedError) and not OUT.headerp:
375 page('error.fhtml',
376 headers = dict(status = e.code),
377 title = 'Chopwood: error', error = e)
378 else:
379 exty, exval, extb = SYS.exc_info()
380 with tmplkw(exception = TB.format_exception_only(exty, exval),
381 traceback = TB.extract_tb(extb),
382 PARAM = sorted(PARAM),
383 COOKIE = sorted(COOKIE.items()),
384 PATH = PATH,
385 ENV = sorted(ENV.items())):
386 if OUT.headerp:
387 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
388 else:
389 page('exception.fhtml',
390 headers = dict(status = 500),
391 title = 'Chopwood: internal error',
392 toplevel = True)
393
394 ###--------------------------------------------------------------------------
395 ### CGI input.
396
397 ## Lots of global variables to be filled in by `cgiparse'.
398 COOKIE = {}
399 SPECIAL = {}
400 PARAM = []
401 PARAMDICT = {}
402 PATH = []
403
404 ## Regular expressions for splitting apart query and cookie strings.
405 R_QSPLIT = RX.compile('[;&]')
406 R_CSPLIT = RX.compile(';')
407
408 def split_keyvalue(string, delim, default):
409 """
410 Split a STRING, and generate the resulting KEY=VALUE pairs.
411
412 The string is split at DELIM; the components are parsed into KEY[=VALUE]
413 pairs. The KEYs and VALUEs are stripped of leading and trailing
414 whitespace, and form-url-decoded. If the VALUE is omitted, then the
415 DEFAULT is used unless the DEFAULT is `None' in which case the component is
416 simply ignored.
417 """
418 for kv in delim.split(string):
419 try:
420 k, v = kv.split('=', 1)
421 except ValueError:
422 if default is None: continue
423 else: k, v = kv, default
424 k, v = k.strip(), v.strip()
425 if not k: continue
426 k, v = urldecode(k), urldecode(v)
427 yield k, v
428
429 def cgiparse():
430 """
431 Process all of the various exciting CGI environment variables.
432
433 We read environment variables and populate some tables left in global
434 variables: it's all rather old-school. Variables set are as follows.
435
436 `COOKIE'
437 A dictionary mapping cookie names to the values provided by the user
438 agent.
439
440 `SPECIAL'
441 A dictionary holding some special query parameters which are of
442 interest at a global level, and should not be passed to a subcommand
443 handler. No new entries will be added to this dictionary, though
444 values will be modified to reflect the query parameters discovered.
445 Conventionally, such parameters have names beginning with `%'.
446
447 `PARAM'
448 The query parameters as a list of (KEY, VALUE) pairs. Special
449 parameters are omitted.
450
451 `PARAMDICT'
452 The query parameters as a dictionary. Special parameters, and
453 parameters which appear more than once, are omitted.
454
455 `PATH'
456 The trailing `PATH_INFO' path, split at `/' markers, with any
457 trailing empty component removed.
458 """
459
460 def getenv(var):
461 try: return ENV[var]
462 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
463
464 ## Yes, we want the request method.
465 method = getenv('REQUEST_METHOD')
466
467 ## Acquire the query string.
468 if method == 'GET':
469 q = getenv('QUERY_STRING')
470
471 elif method == 'POST':
472
473 ## We must read the query string from stdin.
474 n = getenv('CONTENT_LENGTH')
475 if not n.isdigit():
476 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
477 n = int(n, 10)
478 if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
479 raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
480 q = SYS.stdin.read(n)
481 if len(q) != n:
482 raise U.ExpectedError, (500, "Failed to read correct length")
483
484 else:
485 raise U.ExpectedError, (500, "Unexpected request method `%s'" % method)
486
487 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
488 seen = set()
489 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
490 if k in SPECIAL:
491 SPECIAL[k] = v
492 else:
493 PARAM.append((k, v))
494 if k in seen:
495 del PARAMDICT[k]
496 else:
497 PARAMDICT[k] = v
498 seen.add(k)
499
500 ## Parse out the cookies, if any.
501 try: c = ENV['HTTP_COOKIE']
502 except KeyError: pass
503 else:
504 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
505
506 ## Set up the `PATH'.
507 try: p = ENV['PATH_INFO']
508 except KeyError: pass
509 else:
510 pp = p.lstrip('/').split('/')
511 if pp and not pp[-1]: pp.pop()
512 PATH[:] = pp
513
514 ###--------------------------------------------------------------------------
515 ### CGI subcommands.
516
517 class Subcommand (SC.Subcommand):
518 """
519 A CGI subcommand object.
520
521 As for `subcommand.Subcommand', but with additional protocol for processing
522 CGI parameters.
523 """
524
525 def cgi(me, param, path):
526 """
527 Invoke the subcommand given a collection of CGI parameters.
528
529 PARAM is a list of (KEY, VALUE) pairs from the CGI query. The CGI query
530 parameters are checked against the subcommand's parameters (making sure
531 that mandatory parameters are supplied, that any switches are given
532 boolean values, and that only the `rest' parameter, if any, is
533 duplicated).
534
535 PATH is a list of trailing path components. They are used to satisfy the
536 `rest' parameter if there is one and there are no query parameters which
537 satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
538 the list of path elements is non-empty.
539 """
540
541 ## We're going to make a pass over the supplied parameters, and we'll
542 ## check them off against the formal parameters as we go; so we'll need
543 ## to be able to look them up. We'll also keep track of the ones we've
544 ## seen so that we can make sure that all of the mandatory parameters
545 ## were actually supplied.
546 ##
547 ## To that end: `want' is a dictionary mapping parameter names to
548 ## functions which will do something useful with the value; `seen' is a
549 ## set of the parameters which have been assigned; and `kw' is going to
550 ## be the keyword-argument dictionary we pass to the handler function.
551 want = {}
552 kw = {}
553
554 def set_value(k, v):
555 """Set a simple value: we shouldn't see multiple values."""
556 if k in kw:
557 raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
558 kw[k] = v
559 def set_bool(k, v):
560 """Set a simple boolean value: for switches."""
561 set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
562 def set_list(k, v):
563 """Append the value to a list: for the `rest' parameter."""
564 kw.setdefault(k, []).append(v)
565
566 ## Set up the `want' map.
567 for o in me.opts:
568 if o.argname: want[o.name] = set_value
569 else: want[o.name] = set_bool
570 for p in me.params: want[p.name] = set_value
571 for p in me.oparams: want[p.name] = set_value
572 if me.rparam: want[me.rparam.name] = set_list
573
574 ## Work through the list of supplied parameters.
575 for k, v in param:
576 try:
577 f = want[k]
578 except KeyError:
579 if v:
580 raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
581 else:
582 f(k, v)
583
584 ## Deal with a path, if there is one.
585 if path:
586 if me.rparam and me.rparam.name not in kw:
587 kw[me.rparam.name] = path
588 else:
589 raise U.ExpectedError, (404, "Superfluous path elements")
590
591 ## Make sure we saw all of the mandatory parameters.
592 for p in me.params:
593 if p.name not in kw:
594 raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
595
596 ## Invoke the subcommand.
597 me.func(**kw)
598
599 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
600 """Decorator for defining CGI subcommands."""
601 return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw)
602
603 ###----- That's all, folks --------------------------------------------------