chiark / gitweb /
service.py: Yet more unqualified names needing qualification.
[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 _quotify = U.StringSubst({
81   "`": '&lsquo;',
82   "'": '&rsquo;',
83   "``": '&ldquo;',
84   "''": '&rdquo;',
85   "--": '&ndash;',
86   "---": '&mdash;'
87 })
88 def html_quotify(s):
89   """Return a pretty HTML version of S."""
90   return _quotify(htmlescape(s))
91
92 ###--------------------------------------------------------------------------
93 ### Output machinery.
94
95 class HTTPOutput (O.FileOutput):
96   """
97   Output driver providing an automatic HTTP header.
98
99   The `headerp' attribute is true if we've written a header.  The `header'
100   method will print a custom header if this is wanted.
101   """
102
103   def __init__(me, *args, **kw):
104     """Constructor: initialize `headerp' flag."""
105     super(HTTPOutput, me).__init__(*args, **kw)
106     me.headerp = False
107
108   def write(me, msg):
109     """Output protocol: print a header if we've not written one already."""
110     if not me.headerp: me.header('text/plain')
111     super(HTTPOutput, me).write(msg)
112
113   def header(me, content_type = 'text/plain', **kw):
114     """
115     Print a header, if none has yet been printed.
116
117     Keyword arguments can be passed to emit HTTP headers: see `http_header'
118     for the formatting rules.
119     """
120     if me.headerp: return
121     me.headerp = True
122     for h in O.http_headers(content_type = content_type, **kw):
123       me.writeln(h)
124     me.writeln('')
125
126 def cookie(name, value, **kw):
127   """
128   Return a HTTP `Set-Cookie' header.
129
130   The NAME and VALUE give the name and value of the cookie; both are
131   form-url-encoded to prevent misinterpretation (fortunately, `cgiparse'
132   knows to undo this transformation).  The KW are other attributes to
133   declare: the names are forced to lower-case and underscores `_' are
134   replaced by hyphens `-'; a `True' value is assumed to indicate that the
135   attribute is boolean, and omitted.
136   """
137   attr = {}
138   for k, v in kw.iteritems():
139     k = '-'.join(i.lower() for i in k.split('_'))
140     attr[k] = v
141   try: maxage = int(attr['max-age'])
142   except KeyError: pass
143   else:
144     attr['expires'] = T.strftime('%a, %d %b %Y %H:%M:%S GMT',
145                                  T.gmtime(U.NOW + maxage))
146   return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
147                    [v is not True and '%s=%s' % (k, v) or k
148                     for k, v in attr.iteritems() if v])
149
150 def action(*v, **kw):
151   """
152   Build a URL invoking this script.
153
154   The positional arguments V are used to construct a path which is appended
155   to the (deduced or configured) script name (and presumably will be read
156   back as `PATH_INFO').  The keyword arguments are (form-url-encoded and)
157   appended as a query string, if present.
158   """
159   url = '/'.join([CFG.SCRIPT_NAME] + list(v))
160   if kw:
161     url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
162                           for k in sorted(kw))
163   return htmlescape(url)
164
165 def static(name):
166   """Build a URL for the static file NAME."""
167   return htmlescape(CFG.STATIC + '/' + name)
168
169 def redirect(where, **kw):
170   """
171   Write a complete redirection to some other URL.
172   """
173   OUT.header(content_type = 'text/html',
174              status = 302, location = where,
175              **kw)
176   PRINT("""\
177 <html>
178 <head><title>No, sorry, it's moved again.</title></head>
179 <body><p>I'm <a href="%s">over here</a> now.<body>
180 </html>""" % htmlescape(where))
181
182 ###--------------------------------------------------------------------------
183 ### Templates.
184
185 ## Where we find our templates.
186 TMPLDIR = HOME
187
188 ## Keyword arguments for templates.
189 STATE = U.Fluid()
190 STATE.kw = {}
191
192 ## Set some basic keyword arguments.
193 @CONF.hook
194 def set_template_keywords():
195   STATE.kw.update(
196     package = PACKAGE,
197     version = VERSION,
198     script = CFG.SCRIPT_NAME,
199     static = CFG.STATIC)
200
201 class TemplateFinder (object):
202   """
203   A magical fake dictionary whose keys are templates.
204   """
205   def __init__(me, dir):
206     me._cache = {}
207     me._dir = dir
208   def __getitem__(me, key):
209     try: return me._cache[key]
210     except KeyError: pass
211     with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
212     me._cache[key] = tmpl
213     return tmpl
214 TMPL = TemplateFinder(TMPLDIR)
215
216 @CTX.contextmanager
217 def tmplkw(**kw):
218   """
219   Context manager: execute the body with additional keyword arguments
220   """
221   d = dict()
222   d.update(STATE.kw)
223   d.update(kw)
224   with STATE.bind(kw = d): yield
225
226 FORMATOPS = {}
227
228 class FormatHTML (F.SimpleFormatOperation):
229   """
230   ~H: escape output suitable for inclusion in HTML.
231
232   With `:', instead apply form-urlencoding.
233   """
234   def _convert(me, arg):
235     if me.colonp: return html_quotify(arg)
236     else: return htmlescape(arg)
237 FORMATOPS['H'] = FormatHTML
238
239 def format_tmpl(control, **kw):
240   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
241     with tmplkw(**kw):
242       F.format(OUT, control, **STATE.kw)
243
244 def page(template, header = {}, title = 'Chopwood', **kw):
245   header = dict(header, content_type = 'text/html')
246   OUT.header(**header)
247   format_tmpl(TMPL['wrapper.fhtml'],
248               title = title, payload = TMPL[template], **kw)
249
250 ###--------------------------------------------------------------------------
251 ### Error reporting.
252
253 @CTX.contextmanager
254 def cgi_errors(hook = None):
255   """
256   Context manager: report errors in the body as useful HTML.
257
258   If HOOK is given, then call it before reporting errors.  It may have set up
259   useful stuff.
260   """
261   try:
262     yield None
263   except Exception, e:
264     if hook: hook()
265     if isinstance(e, U.ExpectedError) and not OUT.headerp:
266       page('error.fhtml',
267            header = dict(status = e.code),
268            title = 'Chopwood: error', error = e)
269     else:
270       exty, exval, extb = SYS.exc_info()
271       with tmplkw(exception = TB.format_exception_only(exty, exval),
272                   traceback = TB.extract_tb(extb),
273                   PARAM = sorted(PARAM),
274                   COOKIE = sorted(COOKIE.items()),
275                   PATH = PATH,
276                   ENV = sorted(ENV.items())):
277         if OUT.headerp:
278           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
279         else:
280           page('exception.fhtml',
281                header = dict(status = 500),
282                title = 'Chopwood: internal error',
283                toplevel = True)
284
285 ###--------------------------------------------------------------------------
286 ### CGI input.
287
288 ## Lots of global variables to be filled in by `cgiparse'.
289 COOKIE = {}
290 SPECIAL = {}
291 PARAM = []
292 PARAMDICT = {}
293 PATH = []
294 SSLP = False
295
296 ## Regular expressions for splitting apart query and cookie strings.
297 R_QSPLIT = RX.compile('[;&]')
298 R_CSPLIT = RX.compile(';')
299
300 def split_keyvalue(string, delim, default):
301   """
302   Split a STRING, and generate the resulting KEY=VALUE pairs.
303
304   The string is split at DELIM; the components are parsed into KEY[=VALUE]
305   pairs.  The KEYs and VALUEs are stripped of leading and trailing
306   whitespace, and form-url-decoded.  If the VALUE is omitted, then the
307   DEFAULT is used unless the DEFAULT is `None' in which case the component is
308   simply ignored.
309   """
310   for kv in delim.split(string):
311     try:
312       k, v = kv.split('=', 1)
313     except ValueError:
314       if default is None: continue
315       else: k, v = kv, default
316     k, v = k.strip(), v.strip()
317     if not k: continue
318     k, v = urldecode(k), urldecode(v)
319     yield k, v
320
321 def cgiparse():
322   """
323   Process all of the various exciting CGI environment variables.
324
325   We read environment variables and populate some tables left in global
326   variables: it's all rather old-school.  Variables set are as follows.
327
328   `COOKIE'
329         A dictionary mapping cookie names to the values provided by the user
330         agent.
331
332   `SPECIAL'
333         A dictionary holding some special query parameters which are of
334         interest at a global level, and should not be passed to a subcommand
335         handler.  No new entries will be added to this dictionary, though
336         values will be modified to reflect the query parameters discovered.
337         Conventionally, such parameters have names beginning with `%'.
338
339   `PARAM'
340         The query parameters as a list of (KEY, VALUE) pairs.  Special
341         parameters are omitted.
342
343   `PARAMDICT'
344         The query parameters as a dictionary.  Special parameters, and
345         parameters which appear more than once, are omitted.
346
347   `PATH'
348         The trailing `PATH_INFO' path, split at `/' markers, with any
349         trailing empty component removed.
350
351   `SSLP'
352         True if the client connection is carried over SSL or TLS.
353   """
354
355   global SSLP
356
357   def getenv(var):
358     try: return ENV[var]
359     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
360
361   ## Yes, we want the request method.
362   method = getenv('REQUEST_METHOD')
363
364   ## Acquire the query string.
365   if method == 'GET':
366     q = getenv('QUERY_STRING')
367
368   elif method == 'POST':
369
370     ## We must read the query string from stdin.
371     n = getenv('CONTENT_LENGTH')
372     if not n.isdigit():
373       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
374     n = int(n, 10)
375     if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
376       raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
377     q = SYS.stdin.read(n)
378     if len(q) != n:
379       raise U.ExpectedError, (500, "Failed to read correct length")
380
381   else:
382     raise U.ExpectedError, (500, "Unexpected request method `%s'" % method)
383
384   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
385   seen = set()
386   for k, v in split_keyvalue(q, R_QSPLIT, 't'):
387     if k in SPECIAL:
388       SPECIAL[k] = v
389     else:
390       PARAM.append((k, v))
391       if k in seen:
392         del PARAMDICT[k]
393       else:
394         PARAMDICT[k] = v
395         seen.add(k)
396
397   ## Parse out the cookies, if any.
398   try: c = ENV['HTTP_COOKIE']
399   except KeyError: pass
400   else:
401     for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
402
403   ## Set up the `PATH'.
404   try: p = ENV['PATH_INFO']
405   except KeyError: pass
406   else:
407     pp = p.lstrip('/').split('/')
408     if pp and not pp[-1]: pp.pop()
409     PATH[:] = pp
410
411   ## Check the crypto for the connection.
412   if ENV.get('SSL_PROTOCOL'):
413     SSLP = True
414
415 ###--------------------------------------------------------------------------
416 ### CGI subcommands.
417
418 class Subcommand (SC.Subcommand):
419   """
420   A CGI subcommand object.
421
422   As for `subcommand.Subcommand', but with additional protocol for processing
423   CGI parameters.
424   """
425
426   def cgi(me, param, path):
427     """
428     Invoke the subcommand given a collection of CGI parameters.
429
430     PARAM is a list of (KEY, VALUE) pairs from the CGI query.  The CGI query
431     parameters are checked against the subcommand's parameters (making sure
432     that mandatory parameters are supplied, that any switches are given
433     boolean values, and that only the `rest' parameter, if any, is
434     duplicated).
435
436     PATH is a list of trailing path components.  They are used to satisfy the
437     `rest' parameter if there is one and there are no query parameters which
438     satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if
439     the list of path elements is non-empty.
440     """
441
442     ## We're going to make a pass over the supplied parameters, and we'll
443     ## check them off against the formal parameters as we go; so we'll need
444     ## to be able to look them up.  We'll also keep track of the ones we've
445     ## seen so that we can make sure that all of the mandatory parameters
446     ## were actually supplied.
447     ##
448     ## To that end: `want' is a dictionary mapping parameter names to
449     ## functions which will do something useful with the value; `seen' is a
450     ## set of the parameters which have been assigned; and `kw' is going to
451     ## be the keyword-argument dictionary we pass to the handler function.
452     want = {}
453     kw = {}
454
455     def set_value(k, v):
456       """Set a simple value: we shouldn't see multiple values."""
457       if k in kw:
458         raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
459       kw[k] = v
460     def set_bool(k, v):
461       """Set a simple boolean value: for switches."""
462       set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
463     def set_list(k, v):
464       """Append the value to a list: for the `rest' parameter."""
465       kw.setdefault(k, []).append(v)
466
467     ## Set up the `want' map.
468     for o in me.opts:
469       if o.argname: want[o.name] = set_value
470       else: want[o.name] = set_bool
471     for p in me.params: want[p.name] = set_value
472     for p in me.oparams: want[p.name] = set_value
473     if me.rparam: want[me.rparam.name] = set_list
474
475     ## Work through the list of supplied parameters.
476     for k, v in param:
477       try:
478         f = want[k]
479       except KeyError:
480         if v:
481           raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
482       else:
483         f(k, v)
484
485     ## Deal with a path, if there is one.
486     if path:
487       if me.rparam and me.rparam.name not in kw:
488         kw[me.rparam.name] = path
489       else:
490         raise U.ExpectedError, (404, "Superfluous path elements")
491
492     ## Make sure we saw all of the mandatory parameters.
493     for p in me.params:
494       if p.name not in kw:
495         raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
496
497     ## Invoke the subcommand.
498     me.func(**kw)
499
500 def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw):
501   """Decorator for defining CGI subcommands."""
502   return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw)
503
504 ###----- That's all, folks --------------------------------------------------