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)
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)
115 def header(me, content_type = 'text/plain', **kw):
117 Print a header, if none has yet been printed.
119 Keyword arguments can be passed to emit HTTP headers: see `http_headers'
120 for the formatting rules.
122 if me.headerp: return
124 for h in O.http_headers(content_type = content_type, **kw):
132 Report a warning message.
134 The warning is stashed in a list where it can be retrieved using
137 me.warnings.append(msg)
139 def cookie(name, value, **kw):
141 Return a HTTP `Set-Cookie' header.
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.
151 for k, v in kw.iteritems():
152 k = '-'.join(i.lower() for i in k.split('_'))
154 try: maxage = int(attr['max-age'])
155 except KeyError: pass
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
161 for k, v in attr.iteritems() if v])
163 def action(*v, **kw):
165 Build a URL invoking this script.
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.
172 url = '/'.join([CFG.SCRIPT_NAME] + list(v))
174 url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
176 return htmlescape(url)
179 """Build a URL for the static file NAME."""
180 return htmlescape(CFG.STATIC + '/' + name)
182 def redirect(where, **kw):
184 Write a complete redirection to some other URL.
186 OUT.header(content_type = 'text/html',
187 status = 302, location = where,
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))
195 ###--------------------------------------------------------------------------
198 ## Where we find our templates.
201 ## Keyword arguments for templates.
205 ## Set some basic keyword arguments.
207 def set_template_keywords():
211 script = CFG.SCRIPT_NAME,
213 allowop = CFG.ALLOWOP)
215 class TemplateFinder (object):
217 A magical fake dictionary whose keys are templates.
219 def __init__(me, 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
228 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
233 Context manager: execute the body with additional keyword arguments
238 with STATE.bind(kw = d): yield
242 class FormatHTML (F.SimpleFormatOperation):
244 ~H: escape output suitable for inclusion in HTML.
246 With `:', additionally apply quotification.
248 def _convert(me, arg):
249 if me.colonp: return html_quotify(arg)
250 else: return htmlescape(arg)
251 FORMATOPS['H'] = FormatHTML
253 class FormatWrap (F.BaseFormatOperation):
255 ~<...~@>: wrap enclosed material in another formatting control string.
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'.
262 def __init__(me, *args):
263 super(FormatWrap, me).__init__(*args)
266 piece, delim = F.collect_subformat('>;')
268 if delim.char == '>': break
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)):
274 FORMATOPS['<'] = FormatWrap
276 def format_tmpl(control, **kw):
277 with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
279 F.format(OUT, control, **STATE.kw)
281 def page(template, header = {}, title = 'Chopwood', **kw):
282 header = dict(header, content_type = 'text/html')
284 format_tmpl(TMPL['wrapper.fhtml'],
285 title = title, warnings = OUT.warnings,
286 payload = TMPL[template], **kw)
288 ###--------------------------------------------------------------------------
292 def cgi_errors(hook = None):
294 Context manager: report errors in the body as useful HTML.
296 If HOOK is given, then call it before reporting errors. It may have set up
303 if isinstance(e, U.ExpectedError) and not OUT.headerp:
305 header = dict(status = e.code),
306 title = 'Chopwood: error', error = e)
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()),
314 ENV = sorted(ENV.items())):
316 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
318 page('exception.fhtml',
319 header = dict(status = 500),
320 title = 'Chopwood: internal error',
323 ###--------------------------------------------------------------------------
326 ## Lots of global variables to be filled in by `cgiparse'.
334 HEADER_DONE = lambda: None
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.
392 True if the client connection is carried over SSL or TLS.
399 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
401 ## Yes, we want the request method.
402 METHOD = getenv('REQUEST_METHOD')
404 ## Acquire the query string.
405 if METHOD in ['GET', 'HEAD']:
406 q = getenv('QUERY_STRING')
408 elif METHOD == 'POST':
410 ## We must read the query string from stdin.
411 n = getenv('CONTENT_LENGTH')
413 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
415 ct = getenv('CONTENT_TYPE')
416 if ct != 'application/x-www-form-urlencoded':
417 raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
418 q = SYS.stdin.read(n)
420 raise U.ExpectedError, (500, "Failed to read correct length")
423 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
425 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
427 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
433 try: del PARAMDICT[k]
434 except KeyError: pass
439 ## Parse out the cookies, if any.
440 try: c = ENV['HTTP_COOKIE']
441 except KeyError: pass
443 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
445 ## Set up the `PATH'.
446 try: p = ENV['PATH_INFO']
447 except KeyError: pass
449 pp = p.lstrip('/').split('/')
450 if pp and not pp[-1]: pp.pop()
453 ## Check the crypto for the connection.
454 if ENV.get('SSL_PROTOCOL'):
457 ###--------------------------------------------------------------------------
460 class Subcommand (SC.Subcommand):
462 A CGI subcommand object.
464 As for `subcommand.Subcommand', but with additional protocol for processing
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)
473 def cgi(me, param, path):
475 Invoke the subcommand given a collection of CGI parameters.
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
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.
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.
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.
504 ## Check the request method against the permitted list.
506 if meth == 'HEAD': meth = 'GET'
507 if meth not in me.methods:
508 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
511 """Set a simple value: we shouldn't see multiple values."""
513 raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
516 """Set a simple boolean value: for switches."""
517 set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
519 """Append the value to a list: for the `rest' parameter."""
520 kw.setdefault(k, []).append(v)
522 ## Set up the `want' map.
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
530 ## Work through the list of supplied parameters.
536 raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
540 ## Deal with a path, if there is one.
542 if me.rparam and me.rparam.name not in kw:
543 kw[me.rparam.name] = path
545 raise U.ExpectedError, (404, "Superfluous path elements")
547 ## Make sure we saw all of the mandatory parameters.
550 raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
552 ## Invoke the subcommand.
555 def 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)
559 ###----- That's all, folks --------------------------------------------------