chiark / gitweb /
cgi.py: Export request method from `cgiparse'.
[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
127 def cookie(name, value, **kw):
128   """
129   Return a HTTP `Set-Cookie' header.
130
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.
137   """
138   attr = {}
139   for k, v in kw.iteritems():
140     k = '-'.join(i.lower() for i in k.split('_'))
141     attr[k] = v
142   try: maxage = int(attr['max-age'])
143   except KeyError: pass
144   else:
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])
150
151 def action(*v, **kw):
152   """
153   Build a URL invoking this script.
154
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.
159   """
160   url = '/'.join([CFG.SCRIPT_NAME] + list(v))
161   if kw:
162     url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k]))
163                           for k in sorted(kw))
164   return htmlescape(url)
165
166 def static(name):
167   """Build a URL for the static file NAME."""
168   return htmlescape(CFG.STATIC + '/' + name)
169
170 def redirect(where, **kw):
171   """
172   Write a complete redirection to some other URL.
173   """
174   OUT.header(content_type = 'text/html',
175              status = 302, location = where,
176              **kw)
177   PRINT("""\
178 <html>
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))
182
183 ###--------------------------------------------------------------------------
184 ### Templates.
185
186 ## Where we find our templates.
187 TMPLDIR = HOME
188
189 ## Keyword arguments for templates.
190 STATE = U.Fluid()
191 STATE.kw = {}
192
193 ## Set some basic keyword arguments.
194 @CONF.hook
195 def set_template_keywords():
196   STATE.kw.update(
197     package = PACKAGE,
198     version = VERSION,
199     script = CFG.SCRIPT_NAME,
200     static = CFG.STATIC,
201     allowop = CFG.ALLOWOP)
202
203 class TemplateFinder (object):
204   """
205   A magical fake dictionary whose keys are templates.
206   """
207   def __init__(me, dir):
208     me._cache = {}
209     me._dir = 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
215     return tmpl
216 STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
217
218 @CTX.contextmanager
219 def tmplkw(**kw):
220   """
221   Context manager: execute the body with additional keyword arguments
222   """
223   d = dict()
224   d.update(STATE.kw)
225   d.update(kw)
226   with STATE.bind(kw = d): yield
227
228 FORMATOPS = {}
229
230 class FormatHTML (F.SimpleFormatOperation):
231   """
232   ~H: escape output suitable for inclusion in HTML.
233
234   With `:', additionally apply quotification.
235   """
236   def _convert(me, arg):
237     if me.colonp: return html_quotify(arg)
238     else: return htmlescape(arg)
239 FORMATOPS['H'] = FormatHTML
240
241 class FormatWrap (F.BaseFormatOperation):
242   """
243   ~<...~@>: wrap enclosed material in another formatting control string.
244
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'.
249   """
250   def __init__(me, *args):
251     super(FormatWrap, me).__init__(*args)
252     pieces = []
253     while True:
254       piece, delim = F.collect_subformat('>;')
255       pieces.append(piece)
256       if delim.char == '>': break
257     me.pieces = pieces
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)):
261       op.format()
262 FORMATOPS['<'] = FormatWrap
263
264 def format_tmpl(control, **kw):
265   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
266     with tmplkw(**kw):
267       F.format(OUT, control, **STATE.kw)
268
269 def page(template, header = {}, title = 'Chopwood', **kw):
270   header = dict(header, content_type = 'text/html')
271   OUT.header(**header)
272   format_tmpl(TMPL['wrapper.fhtml'],
273               title = title, payload = TMPL[template], **kw)
274
275 ###--------------------------------------------------------------------------
276 ### Error reporting.
277
278 @CTX.contextmanager
279 def cgi_errors(hook = None):
280   """
281   Context manager: report errors in the body as useful HTML.
282
283   If HOOK is given, then call it before reporting errors.  It may have set up
284   useful stuff.
285   """
286   try:
287     yield None
288   except Exception, e:
289     if hook: hook()
290     if isinstance(e, U.ExpectedError) and not OUT.headerp:
291       page('error.fhtml',
292            header = dict(status = e.code),
293            title = 'Chopwood: error', error = e)
294     else:
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()),
300                   PATH = PATH,
301                   ENV = sorted(ENV.items())):
302         if OUT.headerp:
303           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
304         else:
305           page('exception.fhtml',
306                header = dict(status = 500),
307                title = 'Chopwood: internal error',
308                toplevel = True)
309
310 ###--------------------------------------------------------------------------
311 ### CGI input.
312
313 ## Lots of global variables to be filled in by `cgiparse'.
314 METHOD = None
315 COOKIE = {}
316 SPECIAL = {}
317 PARAM = []
318 PARAMDICT = {}
319 PATH = []
320 SSLP = False
321
322 ## Regular expressions for splitting apart query and cookie strings.
323 R_QSPLIT = RX.compile('[;&]')
324 R_CSPLIT = RX.compile(';')
325
326 def split_keyvalue(string, delim, default):
327   """
328   Split a STRING, and generate the resulting KEY=VALUE pairs.
329
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
334   simply ignored.
335   """
336   for kv in delim.split(string):
337     try:
338       k, v = kv.split('=', 1)
339     except ValueError:
340       if default is None: continue
341       else: k, v = kv, default
342     k, v = k.strip(), v.strip()
343     if not k: continue
344     k, v = urldecode(k), urldecode(v)
345     yield k, v
346
347 def cgiparse():
348   """
349   Process all of the various exciting CGI environment variables.
350
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.
353
354   `COOKIE'
355         A dictionary mapping cookie names to the values provided by the user
356         agent.
357
358   `SPECIAL'
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 `%'.
364
365   `PARAM'
366         The query parameters as a list of (KEY, VALUE) pairs.  Special
367         parameters are omitted.
368
369   `PARAMDICT'
370         The query parameters as a dictionary.  Special parameters, and
371         parameters which appear more than once, are omitted.
372
373   `PATH'
374         The trailing `PATH_INFO' path, split at `/' markers, with any
375         trailing empty component removed.
376
377   `SSLP'
378         True if the client connection is carried over SSL or TLS.
379   """
380
381   global METHOD, SSLP
382
383   def getenv(var):
384     try: return ENV[var]
385     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
386
387   ## Yes, we want the request method.
388   METHOD = getenv('REQUEST_METHOD')
389
390   ## Acquire the query string.
391   if METHOD == 'GET':
392     q = getenv('QUERY_STRING')
393
394   elif METHOD == 'POST':
395
396     ## We must read the query string from stdin.
397     n = getenv('CONTENT_LENGTH')
398     if not n.isdigit():
399       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
400     n = int(n, 10)
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)
404     if len(q) != n:
405       raise U.ExpectedError, (500, "Failed to read correct length")
406
407   else:
408     raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
409
410   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
411   seen = set()
412   for k, v in split_keyvalue(q, R_QSPLIT, 't'):
413     if k in SPECIAL:
414       SPECIAL[k] = v
415     else:
416       PARAM.append((k, v))
417       if k in seen:
418         del PARAMDICT[k]
419       else:
420         PARAMDICT[k] = v
421         seen.add(k)
422
423   ## Parse out the cookies, if any.
424   try: c = ENV['HTTP_COOKIE']
425   except KeyError: pass
426   else:
427     for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v
428
429   ## Set up the `PATH'.
430   try: p = ENV['PATH_INFO']
431   except KeyError: pass
432   else:
433     pp = p.lstrip('/').split('/')
434     if pp and not pp[-1]: pp.pop()
435     PATH[:] = pp
436
437   ## Check the crypto for the connection.
438   if ENV.get('SSL_PROTOCOL'):
439     SSLP = True
440
441 ###--------------------------------------------------------------------------
442 ### CGI subcommands.
443
444 class Subcommand (SC.Subcommand):
445   """
446   A CGI subcommand object.
447
448   As for `subcommand.Subcommand', but with additional protocol for processing
449   CGI parameters.
450   """
451
452   def cgi(me, param, path):
453     """
454     Invoke the subcommand given a collection of CGI parameters.
455
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
460     duplicated).
461
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.
466     """
467
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.
473     ##
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.
478     want = {}
479     kw = {}
480
481     def set_value(k, v):
482       """Set a simple value: we shouldn't see multiple values."""
483       if k in kw:
484         raise U.ExpectedError, (400, "Repeated parameter `%s'" % k)
485       kw[k] = v
486     def set_bool(k, v):
487       """Set a simple boolean value: for switches."""
488       set_value(k, v.lower() in ['true', 't', 'yes', 'y'])
489     def set_list(k, v):
490       """Append the value to a list: for the `rest' parameter."""
491       kw.setdefault(k, []).append(v)
492
493     ## Set up the `want' map.
494     for o in me.opts:
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
500
501     ## Work through the list of supplied parameters.
502     for k, v in param:
503       try:
504         f = want[k]
505       except KeyError:
506         if v:
507           raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k)
508       else:
509         f(k, v)
510
511     ## Deal with a path, if there is one.
512     if path:
513       if me.rparam and me.rparam.name not in kw:
514         kw[me.rparam.name] = path
515       else:
516         raise U.ExpectedError, (404, "Superfluous path elements")
517
518     ## Make sure we saw all of the mandatory parameters.
519     for p in me.params:
520       if p.name not in kw:
521         raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name)
522
523     ## Invoke the subcommand.
524     me.func(**kw)
525
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)
529
530 ###----- That's all, folks --------------------------------------------------