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.
58 if CFG.STATIC is None: CFG.STATIC = CFG.SCRIPT_NAME + '/static'
60 ###--------------------------------------------------------------------------
61 ### Escaping and encoding.
63 ## Some handy regular expressions.
64 R_URLESC = RX.compile('%([0-9a-fA-F]{2})')
65 R_URLBAD = RX.compile('[^-\\w,.!]')
66 R_HTMLBAD = RX.compile('[&<>\'"]')
69 """Decode a single form-url-encoded string S."""
70 return R_URLESC.sub(lambda m: chr(int(m.group(1), 16)),
75 """Encode a single string S using form-url-encoding."""
76 return R_URLBAD.sub(lambda m: '%%%02x' % ord(m.group(0)), s)
79 """Escape a literal string S so that HTML doesn't misinterpret it."""
80 return R_HTMLBAD.sub(lambda m: '&#x%02x;' % ord(m.group(0)), s)
82 ## Some standard character sequences, and HTML entity names for prettier
84 html_quotify = U.StringSubst({
97 ###--------------------------------------------------------------------------
100 class HTTPOutput (O.FileOutput):
102 Output driver providing an automatic HTTP header.
104 The `headerp' attribute is true if we've written a header. The `header'
105 method will print a custom header if this is wanted.
108 def __init__(me, *args, **kw):
109 """Constructor: initialize `headerp' flag."""
110 super(HTTPOutput, me).__init__(*args, **kw)
115 """Output protocol: print a header if we've not written one already."""
116 if not me.headerp: me.header('text/plain')
117 super(HTTPOutput, me).write(msg)
119 def header(me, content_type = 'text/plain', **kw):
121 Print a header, if none has yet been printed.
123 Keyword arguments can be passed to emit HTTP headers: see `http_headers'
124 for the formatting rules.
126 if me.headerp: return
128 for h in O.http_headers(content_type = content_type, **kw):
136 Report a warning message.
138 The warning is stashed in a list where it can be retrieved using
141 me.warnings.append(msg)
143 def cookie(name, value, **kw):
145 Return a HTTP `Set-Cookie' header.
147 The NAME and VALUE give the name and value of the cookie; both are
148 form-url-encoded to prevent misinterpretation (fortunately, `cgiparse'
149 knows to undo this transformation). The KW are other attributes to
150 declare: the names are forced to lower-case and underscores `_' are
151 replaced by hyphens `-'; a `True' value is assumed to indicate that the
152 attribute is boolean, and omitted.
155 for k, v in kw.iteritems():
156 k = '-'.join(i.lower() for i in k.split('_'))
158 try: maxage = int(attr['max-age'])
159 except KeyError: pass
161 attr['expires'] = T.strftime('%a, %d %b %Y %H:%M:%S GMT',
162 T.gmtime(U.NOW + maxage))
163 return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
164 [v is not True and '%s=%s' % (k, v) or k
165 for k, v in attr.iteritems() if v])
167 def action(*v, **kw):
169 Build a URL invoking this script.
171 The positional arguments V are used to construct a path which is appended
172 to the (deduced or configured) script name (and presumably will be read
173 back as `PATH_INFO'). The keyword arguments are (form-url-encoded and)
174 appended as a query string, if present.
176 url = '/'.join([CFG.SCRIPT_NAME] + list(v))
178 url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
180 return htmlescape(url)
183 """Build a URL for the static file NAME."""
184 return htmlescape(CFG.STATIC + '/' + name)
186 def redirect(where, **kw):
188 Write a complete redirection to some other URL.
190 OUT.header(content_type = 'text/html',
191 status = 302, location = where,
195 <head><title>No, sorry, it's moved again.</title></head>
196 <body><p>I'm <a href="%s">over here</a> now.<body>
197 </html>""" % htmlescape(where))
199 ###--------------------------------------------------------------------------
202 ## Where we find our templates.
205 ## Keyword arguments for templates.
209 ## Set some basic keyword arguments.
211 def set_template_keywords():
215 script = CFG.SCRIPT_NAME,
217 allowop = CFG.ALLOWOP)
219 class TemplateFinder (object):
221 A magical fake dictionary whose keys are templates.
223 def __init__(me, dir):
226 def __getitem__(me, key):
227 try: return me._cache[key]
228 except KeyError: pass
229 with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
230 me._cache[key] = tmpl
232 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
237 Context manager: execute the body with additional keyword arguments
242 with STATE.bind(kw = d): yield
246 class FormatHTML (F.SimpleFormatOperation):
248 ~H: escape output suitable for inclusion in HTML.
250 With `:', additionally apply quotification.
252 def _convert(me, arg):
253 if me.colonp: return html_quotify(arg)
254 else: return htmlescape(arg)
255 FORMATOPS['H'] = FormatHTML
257 class FormatWrap (F.BaseFormatOperation):
259 ~<...~@>: wrap enclosed material in another formatting control string.
261 The argument is a formatting control. The enclosed material is split into
262 pieces separated by `~;' markers. The formatting control is performed, and
263 passed the list of pieces (as compiled formatting operations) in the
264 keyword argument `wrapped'.
266 def __init__(me, *args):
267 super(FormatWrap, me).__init__(*args)
270 piece, delim = F.collect_subformat('>;')
272 if delim.char == '>': break
274 def _format(me, atp, colonp):
275 op = F.compile(me.getarg.get())
276 with F.FORMAT.bind(argmap = dict(F.FORMAT.argmap, wrapped = me.pieces)):
278 FORMATOPS['<'] = FormatWrap
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, warnings = OUT.warnings,
290 payload = TMPL[template], **kw)
292 ###--------------------------------------------------------------------------
296 def cgi_errors(hook = None):
298 Context manager: report errors in the body as useful HTML.
300 If HOOK is given, then call it before reporting errors. It may have set up
307 if isinstance(e, U.ExpectedError) and not OUT.headerp:
309 header = dict(status = e.code),
310 title = 'Chopwood: error', error = e)
312 exty, exval, extb = SYS.exc_info()
313 with tmplkw(exception = TB.format_exception_only(exty, exval),
314 traceback = TB.extract_tb(extb),
315 PARAM = sorted(PARAM),
316 COOKIE = sorted(COOKIE.items()),
318 ENV = sorted(ENV.items())):
320 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
322 page('exception.fhtml',
323 header = dict(status = 500),
324 title = 'Chopwood: internal error',
327 ###--------------------------------------------------------------------------
330 ## Lots of global variables to be filled in by `cgiparse'.
338 HEADER_DONE = lambda: None
340 ## Regular expressions for splitting apart query and cookie strings.
341 R_QSPLIT = RX.compile('[;&]')
342 R_CSPLIT = RX.compile(';')
344 def split_keyvalue(string, delim, default):
346 Split a STRING, and generate the resulting KEY=VALUE pairs.
348 The string is split at DELIM; the components are parsed into KEY[=VALUE]
349 pairs. The KEYs and VALUEs are stripped of leading and trailing
350 whitespace, and form-url-decoded. If the VALUE is omitted, then the
351 DEFAULT is used unless the DEFAULT is `None' in which case the component is
354 for kv in delim.split(string):
356 k, v = kv.split('=', 1)
358 if default is None: continue
359 else: k, v = kv, default
360 k, v = k.strip(), v.strip()
362 k, v = urldecode(k), urldecode(v)
367 Process all of the various exciting CGI environment variables.
369 We read environment variables and populate some tables left in global
370 variables: it's all rather old-school. Variables set are as follows.
373 A dictionary mapping cookie names to the values provided by the user
377 A dictionary holding some special query parameters which are of
378 interest at a global level, and should not be passed to a subcommand
379 handler. No new entries will be added to this dictionary, though
380 values will be modified to reflect the query parameters discovered.
381 Conventionally, such parameters have names beginning with `%'.
384 The query parameters as a list of (KEY, VALUE) pairs. Special
385 parameters are omitted.
388 The query parameters as a dictionary. Special parameters, and
389 parameters which appear more than once, are omitted.
392 The trailing `PATH_INFO' path, split at `/' markers, with any
393 trailing empty component removed.
396 True if the client connection is carried over SSL or TLS.
403 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
405 ## Yes, we want the request method.
406 METHOD = getenv('REQUEST_METHOD')
408 ## Acquire the query string.
409 if METHOD in ['GET', 'HEAD']:
410 q = ENV.get('QUERY_STRING', '')
412 elif METHOD == 'POST':
414 ## We must read the query string from stdin.
415 n = getenv('CONTENT_LENGTH')
417 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
419 ct = getenv('CONTENT_TYPE')
420 if ct != 'application/x-www-form-urlencoded':
421 raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
422 q = SYS.stdin.read(n)
424 raise U.ExpectedError, (500, "Failed to read correct length")
427 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
429 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
431 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
437 try: del PARAMDICT[k]
438 except KeyError: pass
443 ## Parse out the cookies, if any.
444 try: c = ENV['HTTP_COOKIE']
445 except KeyError: pass
447 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
449 ## Set up the `PATH'.
450 try: p = ENV['PATH_INFO']
451 except KeyError: pass
453 pp = p.lstrip('/').split('/')
454 if pp and not pp[-1]: pp.pop()
457 ## Check the crypto for the connection.
458 if ENV.get('SSL_PROTOCOL'):
461 ###--------------------------------------------------------------------------
464 class Subcommand (SC.Subcommand):
466 A CGI subcommand object.
468 As for `subcommand.Subcommand', but with additional protocol for processing
472 def __init__(me, name, contexts, desc, func,
473 methods = ['GET', 'POST'], *args, **kw):
474 super(Subcommand, me).__init__(name, contexts, desc, func, *args, **kw)
475 me.methods = set(methods)
477 def cgi(me, param, path):
479 Invoke the subcommand given a collection of CGI parameters.
481 PARAM is a list of (KEY, VALUE) pairs from the CGI query. The CGI query
482 parameters are checked against the subcommand's parameters (making sure
483 that mandatory parameters are supplied, that any switches are given
484 boolean values, and that only the `rest' parameter, if any, is
487 PATH is a list of trailing path components. They are used to satisfy the
488 `rest' parameter if there is one and there are no query parameters which
489 satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
490 the list of path elements is non-empty.
495 ## We're going to make a pass over the supplied parameters, and we'll
496 ## check them off against the formal parameters as we go; so we'll need
497 ## to be able to look them up. We'll also keep track of the ones we've
498 ## seen so that we can make sure that all of the mandatory parameters
499 ## were actually supplied.
501 ## To that end: `want' is a dictionary mapping parameter names to
502 ## functions which will do something useful with the value; `seen' is a
503 ## set of the parameters which have been assigned; and `kw' is going to
504 ## be the keyword-argument dictionary we pass to the handler function.
508 ## Check the request method against the permitted list.
510 if meth == 'HEAD': meth = 'GET'
511 if meth not in me.methods:
512 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
515 """Set a simple value: we shouldn't see multiple values."""
517 raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
520 """Set a simple boolean value: for switches."""
521 set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
523 """Append the value to a list: for the `rest' parameter."""
524 kw.setdefault(k, []).append(v)
526 ## Set up the `want' map.
528 if o.argname: want[o.name] = set_value
529 else: want[o.name] = set_bool
530 for p in me.params: want[p.name] = set_value
531 for p in me.oparams: want[p.name] = set_value
532 if me.rparam: want[me.rparam.name] = set_list
534 ## Work through the list of supplied parameters.
540 raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
544 ## Deal with a path, if there is one.
546 if me.rparam and me.rparam.name not in kw:
547 kw[me.rparam.name] = path
549 raise U.ExpectedError, (404, "Superfluous path elements")
551 ## Make sure we saw all of the mandatory parameters.
554 raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
556 ## Invoke the subcommand.
559 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
560 """Decorator for defining CGI subcommands."""
561 return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw)
563 ###----- That's all, folks --------------------------------------------------