5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__ import with_statement
28 import contextlib as CTX
29 import os as OS; ENV = OS.environ
33 import traceback as TB
35 from auto import HOME, PACKAGE, VERSION
36 import config as CONF; CFG = CONF.CFG
38 import output as O; OUT = O.OUT; PRINT = O.PRINT
39 import subcommand as SC
42 ###--------------------------------------------------------------------------
43 ### Configuration tweaks.
45 _script_name = ENV.get('SCRIPT_NAME', '/cgi-bin/chpwd')
49 ## The URL of this program, when it's run through CGI.
50 SCRIPT_NAME = _script_name,
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')
56 ###--------------------------------------------------------------------------
57 ### Escaping and encoding.
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('[&<>]')
65 """Decode a single form-url-encoded string S."""
66 return R_URLESC.sub(lambda m: chr(int(m.group(1), 16)),
71 """Encode a single string S using form-url-encoding."""
72 return R_URLBAD.sub(lambda m: '%%%02x' % ord(m.group(0)), 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)
78 ## Some standard character sequences, and HTML entity names for prettier
80 _quotify = U.StringSubst({
89 """Return a pretty HTML version of S."""
90 return _quotify(htmlescape(s))
92 ###--------------------------------------------------------------------------
95 class HTTPOutput (O.FileOutput):
97 Output driver providing an automatic HTTP header.
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.
103 def __init__(me, *args, **kw):
104 """Constructor: initialize `headerp' flag."""
105 super(HTTPOutput, me).__init__(*args, **kw)
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)
113 def header(me, content_type = 'text/plain', **kw):
115 Print a header, if none has yet been printed.
117 Keyword arguments can be passed to emit HTTP headers: see `http_header'
118 for the formatting rules.
120 if me.headerp: return
122 for h in O.http_headers(content_type = content_type, **kw):
126 def cookie(name, value, **kw):
128 Return a HTTP `Set-Cookie' header.
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.
138 for k, v in kw.iteritems():
139 k = '-'.join(i.lower() for i in k.split('_'))
141 try: maxage = int(attr['max-age'])
142 except KeyError: pass
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()])
150 def action(*v, **kw):
152 Build a URL invoking this script.
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.
159 url = '/'.join([CFG.SCRIPT_NAME] + list(v))
161 url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
163 return htmlescape(url)
166 """Build a URL for the static file NAME."""
167 return htmlescape(CFG.STATIC + '/' + name)
170 def html(title, **kw):
172 Context manager for HTML output.
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.
179 kw = dict(kw, content_type = 'text/html')
182 ## Write the HTML header.
184 <!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
185 'http://www.w3c.org/TR/html4/strict.dtd'>
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')))
202 <a href="%(about)s">Chopwood</a>, version %(version)s:
203 copyright © 2012 Mark Wooding
207 </html>''' % dict(about = static('about.html'),
210 def redirect(where, **kw):
212 Write a complete redirection to some other URL.
214 OUT.header(content_type = 'text/html',
215 status = 302, location = where,
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))
223 ###--------------------------------------------------------------------------
226 ## Where we find our templates.
229 ## Keyword arguments for templates.
233 ## Set some basic keyword arguments.
235 def set_template_keywords():
239 script = CFG.SCRIPT_NAME,
242 class TemplateFinder (object):
244 A magical fake dictionary whose keys are templates.
246 def __init__(me, 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
255 TMPL = TemplateFinder(TMPLDIR)
260 Context manager: execute the body with additional keyword arguments
265 with STATE.bind(kw = d): yield
269 class FormatHTML (F.SimpleFormatOperation):
271 ~H: escape output suitable for inclusion in HTML.
273 With `:', instead apply form-urlencoding.
275 def _convert(me, arg):
276 if me.colonp: return html_quotify(arg)
277 else: return htmlescape(arg)
278 FORMATOPS['H'] = FormatHTML
280 def format_tmpl(control, **kw):
281 with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
283 F.format(OUT, control, **STATE.kw)
285 def page(template, header = {}, title = 'Chopwood', **kw):
286 header = dict(header, content_type = 'text/html')
288 format_tmpl(TMPL['wrapper.fhtml'],
289 title = title, payload = TMPL[template], **kw)
291 ###--------------------------------------------------------------------------
295 def cgi_errors(hook = None):
297 Context manager: report errors in the body as useful HTML.
299 If HOOK is given, then call it before reporting errors. It may have set up
306 if isinstance(e, U.ExpectedError) and not OUT.headerp:
308 headers = dict(status = e.code),
309 title = 'Chopwood: error', error = e)
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()),
317 ENV = sorted(ENV.items())):
319 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
321 page('exception.fhtml',
322 headers = dict(status = 500),
323 title = 'Chopwood: internal error',
326 ###--------------------------------------------------------------------------
329 ## Lots of global variables to be filled in by `cgiparse'.
336 ## Regular expressions for splitting apart query and cookie strings.
337 R_QSPLIT = RX.compile('[;&]')
338 R_CSPLIT = RX.compile(';')
340 def split_keyvalue(string, delim, default):
342 Split a STRING, and generate the resulting KEY=VALUE pairs.
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
350 for kv in delim.split(string):
352 k, v = kv.split('=', 1)
354 if default is None: continue
355 else: k, v = kv, default
356 k, v = k.strip(), v.strip()
358 k, v = urldecode(k), urldecode(v)
363 Process all of the various exciting CGI environment variables.
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.
369 A dictionary mapping cookie names to the values provided by the user
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 `%'.
380 The query parameters as a list of (KEY, VALUE) pairs. Special
381 parameters are omitted.
384 The query parameters as a dictionary. Special parameters, and
385 parameters which appear more than once, are omitted.
388 The trailing `PATH_INFO' path, split at `/' markers, with any
389 trailing empty component removed.
394 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
396 ## Yes, we want the request method.
397 method = getenv('REQUEST_METHOD')
399 ## Acquire the query string.
401 q = getenv('QUERY_STRING')
403 elif method == 'POST':
405 ## We must read the query string from stdin.
406 n = getenv('CONTENT_LENGTH')
408 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
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)
414 raise U.ExpectedError, (500, "Failed to read correct length")
417 raise U.ExpectedError, (500, "Unexpected request method `%s'" % method)
419 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
421 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
432 ## Parse out the cookies, if any.
433 try: c = ENV['HTTP_COOKIE']
434 except KeyError: pass
436 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
438 ## Set up the `PATH'.
439 try: p = ENV['PATH_INFO']
440 except KeyError: pass
442 pp = p.lstrip('/').split('/')
443 if pp and not pp[-1]: pp.pop()
446 ###--------------------------------------------------------------------------
449 class Subcommand (SC.Subcommand):
451 A CGI subcommand object.
453 As for `subcommand.Subcommand', but with additional protocol for processing
457 def cgi(me, param, path):
459 Invoke the subcommand given a collection of CGI parameters.
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
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.
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.
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.
487 """Set a simple value: we shouldn't see multiple values."""
489 raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
492 """Set a simple boolean value: for switches."""
493 set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
495 """Append the value to a list: for the `rest' parameter."""
496 kw.setdefault(k, []).append(v)
498 ## Set up the `want' map.
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
506 ## Work through the list of supplied parameters.
512 raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
516 ## Deal with a path, if there is one.
518 if me.rparam and me.rparam.name not in kw:
519 kw[me.rparam.name] = path
521 raise U.ExpectedError, (404, "Superfluous path elements")
523 ## Make sure we saw all of the mandatory parameters.
526 raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
528 ## Invoke the subcommand.
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)
535 ###----- That's all, folks --------------------------------------------------