chiark / gitweb /
backend.py: Use configured delimiter for joining fields.
[chopwood] / cgi.py
diff --git a/cgi.py b/cgi.py
index f69646c3f4e9a5e5dbed4f4ec77324b9e989a505..8009eafcd3f5abef6ee15a5c10abae9f602174a8 100644 (file)
--- a/cgi.py
+++ b/cgi.py
@@ -59,7 +59,7 @@ CONF.DEFAULTS.update(
 ## Some handy regular expressions.
 R_URLESC = RX.compile('%([0-9a-fA-F]{2})')
 R_URLBAD = RX.compile('[^-\\w,.!]')
 ## Some handy regular expressions.
 R_URLESC = RX.compile('%([0-9a-fA-F]{2})')
 R_URLBAD = RX.compile('[^-\\w,.!]')
-R_HTMLBAD = RX.compile('[&<>]')
+R_HTMLBAD = RX.compile('[&<>\'"]')
 
 def urldecode(s):
   """Decode a single form-url-encoded string S."""
 
 def urldecode(s):
   """Decode a single form-url-encoded string S."""
@@ -77,17 +77,18 @@ def htmlescape(s):
 
 ## Some standard character sequences, and HTML entity names for prettier
 ## versions.
 
 ## Some standard character sequences, and HTML entity names for prettier
 ## versions.
-_quotify = U.StringSubst({
+html_quotify = U.StringSubst({
+  "<": '&lt;',
+  ">": '&gt;',
+  "&": '&amp;',
   "`": '&lsquo;',
   "'": '&rsquo;',
   "`": '&lsquo;',
   "'": '&rsquo;',
+  '"': '&quot;',
   "``": '&ldquo;',
   "''": '&rdquo;',
   "--": '&ndash;',
   "---": '&mdash;'
 })
   "``": '&ldquo;',
   "''": '&rdquo;',
   "--": '&ndash;',
   "---": '&mdash;'
 })
-def html_quotify(s):
-  """Return a pretty HTML version of S."""
-  return _quotify(htmlescape(s))
 
 ###--------------------------------------------------------------------------
 ### Output machinery.
 
 ###--------------------------------------------------------------------------
 ### Output machinery.
@@ -104,6 +105,7 @@ class HTTPOutput (O.FileOutput):
     """Constructor: initialize `headerp' flag."""
     super(HTTPOutput, me).__init__(*args, **kw)
     me.headerp = False
     """Constructor: initialize `headerp' flag."""
     super(HTTPOutput, me).__init__(*args, **kw)
     me.headerp = False
+    me.warnings = []
 
   def write(me, msg):
     """Output protocol: print a header if we've not written one already."""
 
   def write(me, msg):
     """Output protocol: print a header if we've not written one already."""
@@ -114,7 +116,7 @@ class HTTPOutput (O.FileOutput):
     """
     Print a header, if none has yet been printed.
 
     """
     Print a header, if none has yet been printed.
 
-    Keyword arguments can be passed to emit HTTP headers: see `http_header'
+    Keyword arguments can be passed to emit HTTP headers: see `http_headers'
     for the formatting rules.
     """
     if me.headerp: return
     for the formatting rules.
     """
     if me.headerp: return
@@ -122,6 +124,17 @@ class HTTPOutput (O.FileOutput):
     for h in O.http_headers(content_type = content_type, **kw):
       me.writeln(h)
     me.writeln('')
     for h in O.http_headers(content_type = content_type, **kw):
       me.writeln(h)
     me.writeln('')
+    if METHOD == 'HEAD':
+      HEADER_DONE()
+
+  def warn(me, msg):
+    """
+    Report a warning message.
+
+    The warning is stashed in a list where it can be retrieved using
+    `warnings'.
+    """
+    me.warnings.append(msg)
 
 def cookie(name, value, **kw):
   """
 
 def cookie(name, value, **kw):
   """
@@ -145,7 +158,7 @@ def cookie(name, value, **kw):
                                  T.gmtime(U.NOW + maxage))
   return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
                    [v is not True and '%s=%s' % (k, v) or k
                                  T.gmtime(U.NOW + maxage))
   return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] +
                    [v is not True and '%s=%s' % (k, v) or k
-                    for k, v in attr.iteritems()])
+                    for k, v in attr.iteritems() if v])
 
 def action(*v, **kw):
   """
 
 def action(*v, **kw):
   """
@@ -166,47 +179,6 @@ def static(name):
   """Build a URL for the static file NAME."""
   return htmlescape(CFG.STATIC + '/' + name)
 
   """Build a URL for the static file NAME."""
   return htmlescape(CFG.STATIC + '/' + name)
 
-@CTX.contextmanager
-def html(title, **kw):
-  """
-  Context manager for HTML output.
-
-  Keyword arguments are output as HTTP headers (if no header has been written
-  yet).  A `<head>' element is written, and a `<body>' opened, before the
-  context body is executed; the elements are closed off properly at the end.
-  """
-
-  kw = dict(kw, content_type = 'text/html')
-  OUT.header(**kw)
-
-  ## Write the HTML header.
-  PRINT("""\
-<!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
-          'http://www.w3c.org/TR/html4/strict.dtd'>
-<html>
-<head>
-  <title>%(title)s</title>
-  <link rel=stylesheet type='text/css' media=screen href='%(style)s'>
-  <meta http-equiv='Content-Script-Type' content='text/javascript'>
-  <script type='text/javascript' src='%(script)s'></script>
-</head>""" % dict(title = html_quotify(title),
-                 style = static('chpwd.css'),
-                 script = static('chpwd.js')))
-
-  ## Write the body.
-  PRINT('<body>')
-  yield None
-  PRINT('''\
-
-<div class=credits>
-  <a href="%(about)s">Chopwood</a>, version %(version)s:
-  copyright &copy; 2012 Mark Wooding
-</div>
-
-</body>
-</html>''' % dict(about = static('about.html'),
-                  version = VERSION))
-
 def redirect(where, **kw):
   """
   Write a complete redirection to some other URL.
 def redirect(where, **kw):
   """
   Write a complete redirection to some other URL.
@@ -237,7 +209,8 @@ def set_template_keywords():
     package = PACKAGE,
     version = VERSION,
     script = CFG.SCRIPT_NAME,
     package = PACKAGE,
     version = VERSION,
     script = CFG.SCRIPT_NAME,
-    static = CFG.STATIC)
+    static = CFG.STATIC,
+    allowop = CFG.ALLOWOP)
 
 class TemplateFinder (object):
   """
 
 class TemplateFinder (object):
   """
@@ -252,7 +225,7 @@ class TemplateFinder (object):
     with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
     me._cache[key] = tmpl
     return tmpl
     with open(OS.path.join(me._dir, key)) as f: tmpl = f.read()
     me._cache[key] = tmpl
     return tmpl
-TMPL = TemplateFinder(TMPLDIR)
+STATE.kw['TMPL'] = TMPL = TemplateFinder(TMPLDIR)
 
 @CTX.contextmanager
 def tmplkw(**kw):
 
 @CTX.contextmanager
 def tmplkw(**kw):
@@ -270,13 +243,36 @@ class FormatHTML (F.SimpleFormatOperation):
   """
   ~H: escape output suitable for inclusion in HTML.
 
   """
   ~H: escape output suitable for inclusion in HTML.
 
-  With `:', instead apply form-urlencoding.
+  With `:', additionally apply quotification.
   """
   def _convert(me, arg):
     if me.colonp: return html_quotify(arg)
     else: return htmlescape(arg)
 FORMATOPS['H'] = FormatHTML
 
   """
   def _convert(me, arg):
     if me.colonp: return html_quotify(arg)
     else: return htmlescape(arg)
 FORMATOPS['H'] = FormatHTML
 
+class FormatWrap (F.BaseFormatOperation):
+  """
+  ~<...~@>: wrap enclosed material in another formatting control string.
+
+  The argument is a formatting control.  The enclosed material is split into
+  pieces separated by `~;' markers.  The formatting control is performed, and
+  passed the list of pieces (as compiled formatting operations) in the
+  keyword argument `wrapped'.
+  """
+  def __init__(me, *args):
+    super(FormatWrap, me).__init__(*args)
+    pieces = []
+    while True:
+      piece, delim = F.collect_subformat('>;')
+      pieces.append(piece)
+      if delim.char == '>': break
+    me.pieces = pieces
+  def _format(me, atp, colonp):
+    op = F.compile(me.getarg.get())
+    with F.FORMAT.bind(argmap = dict(F.FORMAT.argmap, wrapped = me.pieces)):
+      op.format()
+FORMATOPS['<'] = FormatWrap
+
 def format_tmpl(control, **kw):
   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
     with tmplkw(**kw):
 def format_tmpl(control, **kw):
   with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]):
     with tmplkw(**kw):
@@ -286,79 +282,12 @@ def page(template, header = {}, title = 'Chopwood', **kw):
   header = dict(header, content_type = 'text/html')
   OUT.header(**header)
   format_tmpl(TMPL['wrapper.fhtml'],
   header = dict(header, content_type = 'text/html')
   OUT.header(**header)
   format_tmpl(TMPL['wrapper.fhtml'],
-              title = title, payload = TMPL[template], **kw)
+              title = title, warnings = OUT.warnings,
+              payload = TMPL[template], **kw)
 
 ###--------------------------------------------------------------------------
 ### Error reporting.
 
 
 ###--------------------------------------------------------------------------
 ### Error reporting.
 
-def cgi_error_guts():
-  """
-  Report an exception while we're acting as a CGI, together with lots of
-  information about our state.
-
-  Our caller has, probably at great expense, arranged that we can format lots
-  of text.
-  """
-
-  ## Grab the exception information.
-  exty, exval, extb = SYS.exc_info()
-
-  ## Print the exception itself.
-  PRINT("""\
-<h2>Exception</h2>
-<pre>%s</pre>""" % html_quotify(
-      '\n'.join(TB.format_exception_only(exty, exval))))
-
-  ## Format a traceback so we can find out what has gone wrong.
-  PRINT("""\
-<h2>Traceback</h2>
-<ol>""")
-  for file, line, func, text in TB.extract_tb(extb, 20):
-    PRINT("<li><b>%s</b>:%d (<b>%s</b>)" % (
-      htmlescape(file), line, htmlescape(func)))
-    if text is not None:
-      PRINT("<br><tt>%s</tt>" % htmlescape(text))
-  PRINT("</ol>")
-
-  ## Format various useful tables.
-  def fmt_dict(d):
-    fmt_kvlist(d.iteritems())
-  def fmt_kvlist(l):
-    for k, v in sorted(l):
-      PRINT("<tr><th align=right valign=top>%s<td><tt>%s</tt>" % (
-        htmlescape(k), htmlescape(v)))
-  def fmt_list(l):
-    for i in l:
-      PRINT("<tr><tt>%s</tt>" % htmlescape(i))
-
-  PRINT("""\
-<h2>Parameters</h2>""")
-  for what, thing, how in [('Query', PARAM, fmt_kvlist),
-                           ('Cookies', COOKIE, fmt_dict),
-                           ('Path', PATH, fmt_list),
-                           ('Environment', ENV, fmt_dict)]:
-    PRINT("<h3>%s</h3>\n<table>" % what)
-    how(thing)
-    PRINT("</table>")
-
-def cgi_error():
-  """
-  Report an exception while in CGI mode.
-
-  If we've not produced a header yet, then we can do that, and produce a
-  status code and everything; otherwise we'll have to make do with a small
-  piece of the page.
-  """
-  if OUT.headerp:
-    PRINT("<div class=exception>")
-    cgi_error_guts()
-    PRINT("</div>\n</body></html>")
-  else:
-    with html("chpwd internal error", status = 500):
-      PRINT("<h1>chpwd internal error</h1>")
-      cgi_error_guts()
-  SYS.exit(1)
-
 @CTX.contextmanager
 def cgi_errors(hook = None):
   """
 @CTX.contextmanager
 def cgi_errors(hook = None):
   """
@@ -373,7 +302,7 @@ def cgi_errors(hook = None):
     if hook: hook()
     if isinstance(e, U.ExpectedError) and not OUT.headerp:
       page('error.fhtml',
     if hook: hook()
     if isinstance(e, U.ExpectedError) and not OUT.headerp:
       page('error.fhtml',
-           headers = dict(status = e.code),
+           header = dict(status = e.code),
            title = 'Chopwood: error', error = e)
     else:
       exty, exval, extb = SYS.exc_info()
            title = 'Chopwood: error', error = e)
     else:
       exty, exval, extb = SYS.exc_info()
@@ -387,7 +316,7 @@ def cgi_errors(hook = None):
           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
         else:
           page('exception.fhtml',
           format_tmpl(TMPL['exception.fhtml'], toplevel = False)
         else:
           page('exception.fhtml',
-               headers = dict(status = 500),
+               header = dict(status = 500),
                title = 'Chopwood: internal error',
                toplevel = True)
 
                title = 'Chopwood: internal error',
                toplevel = True)
 
@@ -395,11 +324,14 @@ def cgi_errors(hook = None):
 ### CGI input.
 
 ## Lots of global variables to be filled in by `cgiparse'.
 ### CGI input.
 
 ## Lots of global variables to be filled in by `cgiparse'.
+METHOD = None
 COOKIE = {}
 SPECIAL = {}
 PARAM = []
 PARAMDICT = {}
 PATH = []
 COOKIE = {}
 SPECIAL = {}
 PARAM = []
 PARAMDICT = {}
 PATH = []
+SSLP = False
+HEADER_DONE = lambda: None
 
 ## Regular expressions for splitting apart query and cookie strings.
 R_QSPLIT = RX.compile('[;&]')
 
 ## Regular expressions for splitting apart query and cookie strings.
 R_QSPLIT = RX.compile('[;&]')
@@ -455,34 +387,40 @@ def cgiparse():
   `PATH'
         The trailing `PATH_INFO' path, split at `/' markers, with any
         trailing empty component removed.
   `PATH'
         The trailing `PATH_INFO' path, split at `/' markers, with any
         trailing empty component removed.
+
+  `SSLP'
+        True if the client connection is carried over SSL or TLS.
   """
 
   """
 
+  global METHOD, SSLP
+
   def getenv(var):
     try: return ENV[var]
     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
 
   ## Yes, we want the request method.
   def getenv(var):
     try: return ENV[var]
     except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var)
 
   ## Yes, we want the request method.
-  method = getenv('REQUEST_METHOD')
+  METHOD = getenv('REQUEST_METHOD')
 
   ## Acquire the query string.
 
   ## Acquire the query string.
-  if method == 'GET':
-    q = getenv('QUERY_STRING')
+  if METHOD in ['GET', 'HEAD']:
+    q = ENV.get('QUERY_STRING', '')
 
 
-  elif method == 'POST':
+  elif METHOD == 'POST':
 
     ## We must read the query string from stdin.
     n = getenv('CONTENT_LENGTH')
     if not n.isdigit():
       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
     n = int(n, 10)
 
     ## We must read the query string from stdin.
     n = getenv('CONTENT_LENGTH')
     if not n.isdigit():
       raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH")
     n = int(n, 10)
-    if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
+    ct = getenv('CONTENT_TYPE')
+    if ct != 'application/x-www-form-urlencoded':
       raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
     q = SYS.stdin.read(n)
     if len(q) != n:
       raise U.ExpectedError, (500, "Failed to read correct length")
 
   else:
       raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct)
     q = SYS.stdin.read(n)
     if len(q) != n:
       raise U.ExpectedError, (500, "Failed to read correct length")
 
   else:
-    raise U.ExpectedError, (500, "Unexpected request method `%s'" % method)
+    raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
 
   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
   seen = set()
 
   ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables.
   seen = set()
@@ -492,7 +430,8 @@ def cgiparse():
     else:
       PARAM.append((k, v))
       if k in seen:
     else:
       PARAM.append((k, v))
       if k in seen:
-        del PARAMDICT[k]
+        try: del PARAMDICT[k]
+        except KeyError: pass
       else:
         PARAMDICT[k] = v
         seen.add(k)
       else:
         PARAMDICT[k] = v
         seen.add(k)
@@ -511,6 +450,10 @@ def cgiparse():
     if pp and not pp[-1]: pp.pop()
     PATH[:] = pp
 
     if pp and not pp[-1]: pp.pop()
     PATH[:] = pp
 
+  ## Check the crypto for the connection.
+  if ENV.get('SSL_PROTOCOL'):
+    SSLP = True
+
 ###--------------------------------------------------------------------------
 ### CGI subcommands.
 
 ###--------------------------------------------------------------------------
 ### CGI subcommands.
 
@@ -522,6 +465,11 @@ class Subcommand (SC.Subcommand):
   CGI parameters.
   """
 
   CGI parameters.
   """
 
+  def __init__(me, name, contexts, desc, func,
+               methods = ['GET', 'POST'], *args, **kw):
+    super(Subcommand, me).__init__(name, contexts, desc, func, *args, **kw)
+    me.methods = set(methods)
+
   def cgi(me, param, path):
     """
     Invoke the subcommand given a collection of CGI parameters.
   def cgi(me, param, path):
     """
     Invoke the subcommand given a collection of CGI parameters.
@@ -538,6 +486,8 @@ class Subcommand (SC.Subcommand):
     the list of path elements is non-empty.
     """
 
     the list of path elements is non-empty.
     """
 
+    global HEADER_DONE
+
     ## We're going to make a pass over the supplied parameters, and we'll
     ## check them off against the formal parameters as we go; so we'll need
     ## to be able to look them up.  We'll also keep track of the ones we've
     ## We're going to make a pass over the supplied parameters, and we'll
     ## check them off against the formal parameters as we go; so we'll need
     ## to be able to look them up.  We'll also keep track of the ones we've
@@ -551,6 +501,12 @@ class Subcommand (SC.Subcommand):
     want = {}
     kw = {}
 
     want = {}
     kw = {}
 
+    ## Check the request method against the permitted list.
+    meth = METHOD
+    if meth == 'HEAD': meth = 'GET'
+    if meth not in me.methods:
+      raise U.ExpectedError, (500, "Unexpected request method `%s'" % METHOD)
+
     def set_value(k, v):
       """Set a simple value: we shouldn't see multiple values."""
       if k in kw:
     def set_value(k, v):
       """Set a simple value: we shouldn't see multiple values."""
       if k in kw: