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