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