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 html_quotify = U.StringSubst({
93 ###--------------------------------------------------------------------------
96 class HTTPOutput (O.FileOutput):
98 Output driver providing an automatic HTTP header.
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.
104 def __init__(me, *args, **kw):
105 """Constructor: initialize `headerp' flag."""
106 super(HTTPOutput, me).__init__(*args, **kw)
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)
114 def header(me, content_type = 'text/plain', **kw):
116 Print a header, if none has yet been printed.
118 Keyword arguments can be passed to emit HTTP headers: see `http_headers'
119 for the formatting rules.
121 if me.headerp: return
123 for h in O.http_headers(content_type = content_type, **kw):
129 def cookie(name, value, **kw):
131 Return a HTTP `Set-Cookie' header.
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.
141 for k, v in kw.iteritems():
142 k = '-'.join(i.lower() for i in k.split('_'))
144 try: maxage = int(attr['max-age'])
145 except KeyError: pass
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
151 for k, v in attr.iteritems() if v])
153 def action(*v, **kw):
155 Build a URL invoking this script.
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.
162 url = '/'.join([CFG.SCRIPT_NAME] + list(v))
164 url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
166 return htmlescape(url)
169 """Build a URL for the static file NAME."""
170 return htmlescape(CFG.STATIC + '/' + name)
172 def redirect(where, **kw):
174 Write a complete redirection to some other URL.
176 OUT.header(content_type = 'text/html',
177 status = 302, location = where,
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))
185 ###--------------------------------------------------------------------------
188 ## Where we find our templates.
191 ## Keyword arguments for templates.
195 ## Set some basic keyword arguments.
197 def set_template_keywords():
201 script = CFG.SCRIPT_NAME,
203 allowop = CFG.ALLOWOP)
205 class TemplateFinder (object):
207 A magical fake dictionary whose keys are templates.
209 def __init__(me, 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
218 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
223 Context manager: execute the body with additional keyword arguments
228 with STATE.bind(kw = d): yield
232 class FormatHTML (F.SimpleFormatOperation):
234 ~H: escape output suitable for inclusion in HTML.
236 With `:', additionally apply quotification.
238 def _convert(me, arg):
239 if me.colonp: return html_quotify(arg)
240 else: return htmlescape(arg)
241 FORMATOPS['H'] = FormatHTML
243 class FormatWrap (F.BaseFormatOperation):
245 ~<...~@>: wrap enclosed material in another formatting control string.
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'.
252 def __init__(me, *args):
253 super(FormatWrap, me).__init__(*args)
256 piece, delim = F.collect_subformat('>;')
258 if delim.char == '>': break
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)):
264 FORMATOPS['<'] = FormatWrap
266 def format_tmpl(control, **kw):
267 with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
269 F.format(OUT, control, **STATE.kw)
271 def page(template, header = {}, title = 'Chopwood', **kw):
272 header = dict(header, content_type = 'text/html')
274 format_tmpl(TMPL['wrapper.fhtml'],
275 title = title, payload = TMPL[template], **kw)
277 ###--------------------------------------------------------------------------
281 def cgi_errors(hook = None):
283 Context manager: report errors in the body as useful HTML.
285 If HOOK is given, then call it before reporting errors. It may have set up
292 if isinstance(e, U.ExpectedError) and not OUT.headerp:
294 header = dict(status = e.code),
295 title = 'Chopwood: error', error = e)
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()),
303 ENV = sorted(ENV.items())):
305 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
307 page('exception.fhtml',
308 header = dict(status = 500),
309 title = 'Chopwood: internal error',
312 ###--------------------------------------------------------------------------
315 ## Lots of global variables to be filled in by `cgiparse'.
323 HEADER_DONE = lambda: None
325 ## Regular expressions for splitting apart query and cookie strings.
326 R_QSPLIT = RX.compile('[;&]')
327 R_CSPLIT = RX.compile(';')
329 def split_keyvalue(string, delim, default):
331 Split a STRING, and generate the resulting KEY=VALUE pairs.
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
339 for kv in delim.split(string):
341 k, v = kv.split('=', 1)
343 if default is None: continue
344 else: k, v = kv, default
345 k, v = k.strip(), v.strip()
347 k, v = urldecode(k), urldecode(v)
352 Process all of the various exciting CGI environment variables.
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.
358 A dictionary mapping cookie names to the values provided by the user
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 `%'.
369 The query parameters as a list of (KEY, VALUE) pairs. Special
370 parameters are omitted.
373 The query parameters as a dictionary. Special parameters, and
374 parameters which appear more than once, are omitted.
377 The trailing `PATH_INFO' path, split at `/' markers, with any
378 trailing empty component removed.
381 True if the client connection is carried over SSL or TLS.
388 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
390 ## Yes, we want the request method.
391 METHOD = getenv('REQUEST_METHOD')
393 ## Acquire the query string.
394 if METHOD in ['GET', 'HEAD']:
395 q = getenv('QUERY_STRING')
397 elif METHOD == 'POST':
399 ## We must read the query string from stdin.
400 n = getenv('CONTENT_LENGTH')
402 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
404 if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
405 raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
406 q = SYS.stdin.read(n)
408 raise U.ExpectedError, (500, "Failed to read correct length")
411 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
413 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
415 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
426 ## Parse out the cookies, if any.
427 try: c = ENV['HTTP_COOKIE']
428 except KeyError: pass
430 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
432 ## Set up the `PATH'.
433 try: p = ENV['PATH_INFO']
434 except KeyError: pass
436 pp = p.lstrip('/').split('/')
437 if pp and not pp[-1]: pp.pop()
440 ## Check the crypto for the connection.
441 if ENV.get('SSL_PROTOCOL'):
444 ###--------------------------------------------------------------------------
447 class Subcommand (SC.Subcommand):
449 A CGI subcommand object.
451 As for `subcommand.Subcommand', but with additional protocol for processing
455 def cgi(me, param, path):
457 Invoke the subcommand given a collection of CGI parameters.
459 PARAM is a list of (KEY, VALUE) pairs from the CGI query. The CGI query
460 parameters are checked against the subcommand's parameters (making sure
461 that mandatory parameters are supplied, that any switches are given
462 boolean values, and that only the `rest' parameter, if any, is
465 PATH is a list of trailing path components. They are used to satisfy the
466 `rest' parameter if there is one and there are no query parameters which
467 satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
468 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 --------------------------------------------------