chiark / gitweb /
backend.py: Separate out the main work of `_update'.
[chopwood] / cgi.py
1 ### -*-python-*-
2 ###
3 ### CGI machinery
4 ###
5 ### (c) 2013 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Chopwood: a password-changing service.
11 ###
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.
16 ###
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.
21 ###
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/>.
25
26 from __future__ import with_statement
27
28 import contextlib as CTX
29 import os as OS; ENV = OS.environ
30 import re as RX
31 import sys as SYS
32 import time as T
33 import traceback as TB
34
35 from auto import HOME, PACKAGE, VERSION
36 import config as CONF; CFG = CONF.CFG
37 import format as F
38 import output as O; OUT = O.OUT; PRINT = O.PRINT
39 import subcommand as SC
40 import util as U
41
42 ###--------------------------------------------------------------------------
43 ### Configuration tweaks.
44
45 _script_name = ENV.get('SCRIPT_NAME', '/cgi-bin/chpwd')
46
47 CONF.DEFAULTS.update(
48
49   ## The URL of this program, when it's run through CGI.
50   SCRIPT_NAME = _script_name,
51
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')
55
56 ###--------------------------------------------------------------------------
57 ### Escaping and encoding.
58
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('[&<>\'"]')
63
64 def urldecode(s):
65   """Decode a single form-url-encoded string S."""
66   return R_URLESC.sub(lambda m: chr(int(m.group(1), 16)),
67                       s.replace('+', ' '))
68   return s
69
70 def urlencode(s):
71   """Encode a single string S using form-url-encoding."""
72   return R_URLBAD.sub(lambda m: '%%%02x' % ord(m.group(0)), s)
73
74 def htmlescape(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)
77
78 ## Some standard character sequences, and HTML entity names for prettier
79 ## versions.
80 html_quotify = U.StringSubst({
81   "<": '&lt;',
82   ">": '&gt;',
83   "&": '&amp;',
84   "`": '&lsquo;',
85   "'": '&rsquo;',
86   '"': '&quot;',
87   "``": '&ldquo;',
88   "''": '&rdquo;',
89   "--": '&ndash;',
90   "---": '&mdash;'
91 })
92
93 ###--------------------------------------------------------------------------
94 ### Output machinery.
95
96 class HTTPOutput (O.FileOutput):
97   """
98   Output driver providing an automatic HTTP header.
99
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.
102   """
103
104   def __init__(me, *args, **kw):
105     """Constructor: initialize `headerp' flag."""
106     super(HTTPOutput, me).__init__(*args, **kw)
107     me.headerp = False
108     me.warnings = []
109
110   def write(me, msg):
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)
114
115   def header(me, content_type = 'text/plain', **kw):
116     """
117     Print a header, if none has yet been printed.
118
119     Keyword arguments can be passed to emit HTTP headers: see `http_headers'
120     for the formatting rules.
121     """
122     if me.headerp: return
123     me.headerp = True
124     for h in O.http_headers(content_type = content_type, **kw):
125       me.writeln(h)
126     me.writeln('')
127     if METHOD == 'HEAD':
128       HEADER_DONE()
129
130   def warn(me, msg):
131     """
132     Report a warning message.
133
134     The warning is stashed in a list where it can be retrieved using
135     `warnings'.
136     """
137     me.warnings.append(msg)
138
139 def cookie(name, value, **kw):
140   """
141   Return a HTTP `Set-Cookie' header.
142
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.
149   """
150   attr = {}
151   for k, v in kw.iteritems():
152     k = '-'.join(i.lower() for i in k.split('_'))
153     attr[k] = v
154   try: maxage = int(attr['max-age'])
155   except KeyError: pass
156   else:
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])
162
163 def action(*v, **kw):
164   """
165   Build a URL invoking this script.
166
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.
171   """
172   url = '/'.join([CFG.SCRIPT_NAME] + list(v))
173   if kw:
174     url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
175                           for k in sorted(kw))
176   return htmlescape(url)
177
178 def static(name):
179   """Build a URL for the static file NAME."""
180   return htmlescape(CFG.STATIC + '/' + name)
181
182 def redirect(where, **kw):
183   """
184   Write a complete redirection to some other URL.
185   """
186   OUT.header(content_type = 'text/html',
187              status = 302, location = where,
188              **kw)
189   PRINT("""\
190 <html>
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))
194
195 ###--------------------------------------------------------------------------
196 ### Templates.
197
198 ## Where we find our templates.
199 TMPLDIR = HOME
200
201 ## Keyword arguments for templates.
202 STATE = U.Fluid()
203 STATE.kw = {}
204
205 ## Set some basic keyword arguments.
206 @CONF.hook
207 def set_template_keywords():
208   STATE.kw.update(
209     package = PACKAGE,
210     version = VERSION,
211     script = CFG.SCRIPT_NAME,
212     static = CFG.STATIC,
213     allowop = CFG.ALLOWOP)
214
215 class TemplateFinder (object):
216   """
217   A magical fake dictionary whose keys are templates.
218   """
219   def __init__(me, dir):
220     me._cache = {}
221     me._dir = 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
227     return tmpl
228 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
229
230 @CTX.contextmanager
231 def tmplkw(**kw):
232   """
233   Context manager: execute the body with additional keyword arguments
234   """
235   d = dict()
236   d.update(STATE.kw)
237   d.update(kw)
238   with STATE.bind(kw = d): yield
239
240 FORMATOPS = {}
241
242 class FormatHTML (F.SimpleFormatOperation):
243   """
244   ~H: escape output suitable for inclusion in HTML.
245
246   With `:', additionally apply quotification.
247   """
248   def _convert(me, arg):
249     if me.colonp: return html_quotify(arg)
250     else: return htmlescape(arg)
251 FORMATOPS['H'] = FormatHTML
252
253 class FormatWrap (F.BaseFormatOperation):
254   """
255   ~<...~@>: wrap enclosed material in another formatting control string.
256
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'.
261   """
262   def __init__(me, *args):
263     super(FormatWrap, me).__init__(*args)
264     pieces = []
265     while True:
266       piece, delim = F.collect_subformat('>;')
267       pieces.append(piece)
268       if delim.char == '>': break
269     me.pieces = pieces
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)):
273       op.format()
274 FORMATOPS['<'] = FormatWrap
275
276 def format_tmpl(control, **kw):
277   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
278     with tmplkw(**kw):
279       F.format(OUT, control, **STATE.kw)
280
281 def page(template, header = {}, title = 'Chopwood', **kw):
282   header = dict(header, content_type = 'text/html')
283   OUT.header(**header)
284   format_tmpl(TMPL['wrapper.fhtml'],
285               title = title, warnings = OUT.warnings,
286               payload = TMPL[template], **kw)
287
288 ###--------------------------------------------------------------------------
289 ### Error reporting.
290
291 @CTX.contextmanager
292 def cgi_errors(hook = None):
293   """
294   Context manager: report errors in the body as useful HTML.
295
296   If HOOK is given, then call it before reporting errors.  It may have set up
297   useful stuff.
298   """
299   try:
300     yield None
301   except Exception, e:
302     if hook: hook()
303     if isinstance(e, U.ExpectedError) and not OUT.headerp:
304       page('error.fhtml',
305            header = dict(status = e.code),
306            title = 'Chopwood: error', error = e)
307     else:
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()),
313                   PATH = PATH,
314                   ENV = sorted(ENV.items())):
315         if OUT.headerp:
316           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
317         else:
318           page('exception.fhtml',
319                header = dict(status = 500),
320                title = 'Chopwood: internal error',
321                toplevel = True)
322
323 ###--------------------------------------------------------------------------
324 ### CGI input.
325
326 ## Lots of global variables to be filled in by `cgiparse'.
327 METHOD = None
328 COOKIE = {}
329 SPECIAL = {}
330 PARAM = []
331 PARAMDICT = {}
332 PATH = []
333 SSLP = False
334 HEADER_DONE = lambda: None
335
336 ## Regular expressions for splitting apart query and cookie strings.
337 R_QSPLIT = RX.compile('[;&]')
338 R_CSPLIT = RX.compile(';')
339
340 def split_keyvalue(string, delim, default):
341   """
342   Split a STRING, and generate the resulting KEY=VALUE pairs.
343
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
348   simply ignored.
349   """
350   for kv in delim.split(string):
351     try:
352       k, v = kv.split('=', 1)
353     except ValueError:
354       if default is None: continue
355       else: k, v = kv, default
356     k, v = k.strip(), v.strip()
357     if not k: continue
358     k, v = urldecode(k), urldecode(v)
359     yield k, v
360
361 def cgiparse():
362   """
363   Process all of the various exciting CGI environment variables.
364
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.
367
368   `COOKIE'
369         A dictionary mapping cookie names to the values provided by the user
370         agent.
371
372   `SPECIAL'
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 `%'.
378
379   `PARAM'
380         The query parameters as a list of (KEY, VALUE) pairs.  Special
381         parameters are omitted.
382
383   `PARAMDICT'
384         The query parameters as a dictionary.  Special parameters, and
385         parameters which appear more than once, are omitted.
386
387   `PATH'
388         The trailing `PATH_INFO' path, split at `/' markers, with any
389         trailing empty component removed.
390
391   `SSLP'
392         True if the client connection is carried over SSL or TLS.
393   """
394
395   global METHOD, SSLP
396
397   def getenv(var):
398     try: return ENV[var]
399     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
400
401   ## Yes, we want the request method.
402   METHOD = getenv('REQUEST_METHOD')
403
404   ## Acquire the query string.
405   if METHOD in ['GET', 'HEAD']:
406     q = getenv('QUERY_STRING')
407
408   elif METHOD == 'POST':
409
410     ## We must read the query string from stdin.
411     n = getenv('CONTENT_LENGTH')
412     if not n.isdigit():
413       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
414     n = int(n, 10)
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)
419     if len(q) != n:
420       raise U.ExpectedError, (500, "Failed to read correct length")
421
422   else:
423     raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
424
425   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
426   seen = set()
427   for k, v in split_keyvalue(q, R_QSPLIT, 't'):
428     if k in SPECIAL:
429       SPECIAL[k] = v
430     else:
431       PARAM.append((k, v))
432       if k in seen:
433         try: del PARAMDICT[k]
434         except KeyError: pass
435       else:
436         PARAMDICT[k] = v
437         seen.add(k)
438
439   ## Parse out the cookies, if any.
440   try: c = ENV['HTTP_COOKIE']
441   except KeyError: pass
442   else:
443     for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
444
445   ## Set up the `PATH'.
446   try: p = ENV['PATH_INFO']
447   except KeyError: pass
448   else:
449     pp = p.lstrip('/').split('/')
450     if pp and not pp[-1]: pp.pop()
451     PATH[:] = pp
452
453   ## Check the crypto for the connection.
454   if ENV.get('SSL_PROTOCOL'):
455     SSLP = True
456
457 ###--------------------------------------------------------------------------
458 ### CGI subcommands.
459
460 class Subcommand (SC.Subcommand):
461   """
462   A CGI subcommand object.
463
464   As for `subcommand.Subcommand', but with additional protocol for processing
465   CGI parameters.
466   """
467
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)
472
473   def cgi(me, param, path):
474     """
475     Invoke the subcommand given a collection of CGI parameters.
476
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
481     duplicated).
482
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.
487     """
488
489     global HEADER_DONE
490
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.
496     ##
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.
501     want = {}
502     kw = {}
503
504     ## Check the request method against the permitted list.
505     meth = METHOD
506     if meth == 'HEAD': meth = 'GET'
507     if meth not in me.methods:
508       raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
509
510     def set_value(k, v):
511       """Set a simple value: we shouldn't see multiple values."""
512       if k in kw:
513         raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
514       kw[k] = v
515     def set_bool(k, v):
516       """Set a simple boolean value: for switches."""
517       set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
518     def set_list(k, v):
519       """Append the value to a list: for the `rest' parameter."""
520       kw.setdefault(k, []).append(v)
521
522     ## Set up the `want' map.
523     for o in me.opts:
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
529
530     ## Work through the list of supplied parameters.
531     for k, v in param:
532       try:
533         f = want[k]
534       except KeyError:
535         if v:
536           raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
537       else:
538         f(k, v)
539
540     ## Deal with a path, if there is one.
541     if path:
542       if me.rparam and me.rparam.name not in kw:
543         kw[me.rparam.name] = path
544       else:
545         raise U.ExpectedError, (404, "Superfluous path elements")
546
547     ## Make sure we saw all of the mandatory parameters.
548     for p in me.params:
549       if p.name not in kw:
550         raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
551
552     ## Invoke the subcommand.
553     me.func(**kw)
554
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)
558
559 ###----- That's all, folks --------------------------------------------------