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