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):
127 def cookie(name, value, **kw):
129 Return a HTTP `Set-Cookie' header.
131 The NAME and VALUE give the name and value of the cookie; both are
132 form-url-encoded to prevent misinterpretation (fortunately, `cgiparse'
133 knows to undo this transformation). The KW are other attributes to
134 declare: the names are forced to lower-case and underscores `_' are
135 replaced by hyphens `-'; a `True' value is assumed to indicate that the
136 attribute is boolean, and omitted.
139 for k, v in kw.iteritems():
140 k = '-'.join(i.lower() for i in k.split('_'))
142 try: maxage = int(attr['max-age'])
143 except KeyError: pass
145 attr['expires'] = T.strftime('%a, %d %b %Y %H:%M:%S GMT',
146 T.gmtime(U.NOW + maxage))
147 return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
148 [v is not True and '%s=%s' % (k, v) or k
149 for k, v in attr.iteritems() if v])
151 def action(*v, **kw):
153 Build a URL invoking this script.
155 The positional arguments V are used to construct a path which is appended
156 to the (deduced or configured) script name (and presumably will be read
157 back as `PATH_INFO'). The keyword arguments are (form-url-encoded and)
158 appended as a query string, if present.
160 url = '/'.join([CFG.SCRIPT_NAME] + list(v))
162 url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
164 return htmlescape(url)
167 """Build a URL for the static file NAME."""
168 return htmlescape(CFG.STATIC + '/' + name)
170 def redirect(where, **kw):
172 Write a complete redirection to some other URL.
174 OUT.header(content_type = 'text/html',
175 status = 302, location = where,
179 <head><title>No, sorry, it's moved again.</title></head>
180 <body><p>I'm <a href="%s">over here</a> now.<body>
181 </html>""" % htmlescape(where))
183 ###--------------------------------------------------------------------------
186 ## Where we find our templates.
189 ## Keyword arguments for templates.
193 ## Set some basic keyword arguments.
195 def set_template_keywords():
199 script = CFG.SCRIPT_NAME,
201 allowop = CFG.ALLOWOP)
203 class TemplateFinder (object):
205 A magical fake dictionary whose keys are templates.
207 def __init__(me, dir):
210 def __getitem__(me, key):
211 try: return me._cache[key]
212 except KeyError: pass
213 with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
214 me._cache[key] = tmpl
216 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
221 Context manager: execute the body with additional keyword arguments
226 with STATE.bind(kw = d): yield
230 class FormatHTML (F.SimpleFormatOperation):
232 ~H: escape output suitable for inclusion in HTML.
234 With `:', additionally apply quotification.
236 def _convert(me, arg):
237 if me.colonp: return html_quotify(arg)
238 else: return htmlescape(arg)
239 FORMATOPS['H'] = FormatHTML
241 class FormatWrap (F.BaseFormatOperation):
243 ~<...~@>: wrap enclosed material in another formatting control string.
245 The argument is a formatting control. The enclosed material is split into
246 pieces separated by `~;' markers. The formatting control is performed, and
247 passed the list of pieces (as compiled formatting operations) in the
248 keyword argument `wrapped'.
250 def __init__(me, *args):
251 super(FormatWrap, me).__init__(*args)
254 piece, delim = F.collect_subformat('>;')
256 if delim.char == '>': break
258 def _format(me, atp, colonp):
259 op = F.compile(me.getarg.get())
260 with F.FORMAT.bind(argmap = dict(F.FORMAT.argmap, wrapped = me.pieces)):
262 FORMATOPS['<'] = FormatWrap
264 def format_tmpl(control, **kw):
265 with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
267 F.format(OUT, control, **STATE.kw)
269 def page(template, header = {}, title = 'Chopwood', **kw):
270 header = dict(header, content_type = 'text/html')
272 format_tmpl(TMPL['wrapper.fhtml'],
273 title = title, payload = TMPL[template], **kw)
275 ###--------------------------------------------------------------------------
279 def cgi_errors(hook = None):
281 Context manager: report errors in the body as useful HTML.
283 If HOOK is given, then call it before reporting errors. It may have set up
290 if isinstance(e, U.ExpectedError) and not OUT.headerp:
292 header = dict(status = e.code),
293 title = 'Chopwood: error', error = e)
295 exty, exval, extb = SYS.exc_info()
296 with tmplkw(exception = TB.format_exception_only(exty, exval),
297 traceback = TB.extract_tb(extb),
298 PARAM = sorted(PARAM),
299 COOKIE = sorted(COOKIE.items()),
301 ENV = sorted(ENV.items())):
303 format_tmpl(TMPL['exception.fhtml'], toplevel = False)
305 page('exception.fhtml',
306 header = dict(status = 500),
307 title = 'Chopwood: internal error',
310 ###--------------------------------------------------------------------------
313 ## Lots of global variables to be filled in by `cgiparse'.
322 ## Regular expressions for splitting apart query and cookie strings.
323 R_QSPLIT = RX.compile('[;&]')
324 R_CSPLIT = RX.compile(';')
326 def split_keyvalue(string, delim, default):
328 Split a STRING, and generate the resulting KEY=VALUE pairs.
330 The string is split at DELIM; the components are parsed into KEY[=VALUE]
331 pairs. The KEYs and VALUEs are stripped of leading and trailing
332 whitespace, and form-url-decoded. If the VALUE is omitted, then the
333 DEFAULT is used unless the DEFAULT is `None' in which case the component is
336 for kv in delim.split(string):
338 k, v = kv.split('=', 1)
340 if default is None: continue
341 else: k, v = kv, default
342 k, v = k.strip(), v.strip()
344 k, v = urldecode(k), urldecode(v)
349 Process all of the various exciting CGI environment variables.
351 We read environment variables and populate some tables left in global
352 variables: it's all rather old-school. Variables set are as follows.
355 A dictionary mapping cookie names to the values provided by the user
359 A dictionary holding some special query parameters which are of
360 interest at a global level, and should not be passed to a subcommand
361 handler. No new entries will be added to this dictionary, though
362 values will be modified to reflect the query parameters discovered.
363 Conventionally, such parameters have names beginning with `%'.
366 The query parameters as a list of (KEY, VALUE) pairs. Special
367 parameters are omitted.
370 The query parameters as a dictionary. Special parameters, and
371 parameters which appear more than once, are omitted.
374 The trailing `PATH_INFO' path, split at `/' markers, with any
375 trailing empty component removed.
378 True if the client connection is carried over SSL or TLS.
385 except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
387 ## Yes, we want the request method.
388 METHOD = getenv('REQUEST_METHOD')
390 ## Acquire the query string.
392 q = getenv('QUERY_STRING')
394 elif METHOD == 'POST':
396 ## We must read the query string from stdin.
397 n = getenv('CONTENT_LENGTH')
399 raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
401 if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
402 raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
403 q = SYS.stdin.read(n)
405 raise U.ExpectedError, (500, "Failed to read correct length")
408 raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
410 ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
412 for k, v in split_keyvalue(q, R_QSPLIT, 't'):
423 ## Parse out the cookies, if any.
424 try: c = ENV['HTTP_COOKIE']
425 except KeyError: pass
427 for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
429 ## Set up the `PATH'.
430 try: p = ENV['PATH_INFO']
431 except KeyError: pass
433 pp = p.lstrip('/').split('/')
434 if pp and not pp[-1]: pp.pop()
437 ## Check the crypto for the connection.
438 if ENV.get('SSL_PROTOCOL'):
441 ###--------------------------------------------------------------------------
444 class Subcommand (SC.Subcommand):
446 A CGI subcommand object.
448 As for `subcommand.Subcommand', but with additional protocol for processing
452 def cgi(me, param, path):
454 Invoke the subcommand given a collection of CGI parameters.
456 PARAM is a list of (KEY, VALUE) pairs from the CGI query. The CGI query
457 parameters are checked against the subcommand's parameters (making sure
458 that mandatory parameters are supplied, that any switches are given
459 boolean values, and that only the `rest' parameter, if any, is
462 PATH is a list of trailing path components. They are used to satisfy the
463 `rest' parameter if there is one and there are no query parameters which
464 satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
465 the list of path elements is non-empty.
468 ## We're going to make a pass over the supplied parameters, and we'll
469 ## check them off against the formal parameters as we go; so we'll need
470 ## to be able to look them up. We'll also keep track of the ones we've
471 ## seen so that we can make sure that all of the mandatory parameters
472 ## were actually supplied.
474 ## To that end: `want' is a dictionary mapping parameter names to
475 ## functions which will do something useful with the value; `seen' is a
476 ## set of the parameters which have been assigned; and `kw' is going to
477 ## be the keyword-argument dictionary we pass to the handler function.
482 """Set a simple value: we shouldn't see multiple values."""
484 raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
487 """Set a simple boolean value: for switches."""
488 set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
490 """Append the value to a list: for the `rest' parameter."""
491 kw.setdefault(k, []).append(v)
493 ## Set up the `want' map.
495 if o.argname: want[o.name] = set_value
496 else: want[o.name] = set_bool
497 for p in me.params: want[p.name] = set_value
498 for p in me.oparams: want[p.name] = set_value
499 if me.rparam: want[me.rparam.name] = set_list
501 ## Work through the list of supplied parameters.
507 raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
511 ## Deal with a path, if there is one.
513 if me.rparam and me.rparam.name not in kw:
514 kw[me.rparam.name] = path
516 raise U.ExpectedError, (404, "Superfluous path elements")
518 ## Make sure we saw all of the mandatory parameters.
521 raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
523 ## Invoke the subcommand.
526 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
527 """Decorator for defining CGI subcommands."""
528 return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw)
530 ###----- That's all, folks --------------------------------------------------