The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/env python
#
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
#
#
# A script intended to be useful in helping to collect signatures for a
# release.  This is a pretty rough, and patches are welcome to improve it.
#
# Some thoughts about future improvement:
#  * Display of per-file and per-release statistics
#  * Make use of the python-gpg package (http://code.google.com/p/python-gnupg/)
#  * Post to IRC when a new signature is collected
#    - Since we don't want to have a long running bot, perhaps we could
#      also patch wayita to accept and then echo a privmsg?
#  * Mail dev@ when somebody submits a successful signature, and include a
#    comments field which could be included in the mail.
#  * Use a subversion repository instead of sqlite backend
#    - no need to re-invent storage and retrieval
#    - perhaps we could re-use existing CIA/mailer hooks?
#

import sys, os
import sqlite3

def make_config():
  'Output a blank config file'

  if os.path.exists('config.py'):
    print "'config.py' already exists!'"
    sys.exit(1)

  conf = open('config.py', 'w')
  conf.write("version = ''\n")
  conf.write("sigdir = ''\n")
  conf.write("filesdir = ''\n")
  conf.close()

  print "'config.py' generated"

def make_db():
  'Initialize a blank database'

  db = sqlite3.connect('sigs.db')
  db.execute('''
    CREATE TABLE signatures (
      keyid TEXT, filename TEXT, signature BLOB,
      UNIQUE(keyid,filename)
    );
''');

# This function is web-facing
def generate_asc_files(target_dir='.'):
  fds = {}
  def _open(filename):
    if not fds.has_key(filename):
      fd = open(os.path.join(target_dir, filename + '.asc'), 'w')
      fds[filename] = fd
    return fds[filename]

  db = sqlite3.connect(os.path.join(target_dir, 'sigs.db'))
  curs = db.cursor()
  curs.execute('SELECT filename, signature FROM signatures;')
  for filename, signature in curs:
    fd = _open(filename)
    fd.write(signature + "\n")

  for fd in fds.values():
    fd.flush()
    fd.close()

actions = {
    'make_config' : make_config,
    'make_db' : make_db,
    'make_asc' : generate_asc_files,
}


if __name__ == '__main__':
  if len(sys.argv) > 1:
    if sys.argv[1] in actions:
      actions[sys.argv[1]]()
      sys.exit(0)


# Stuff below this line is the web-facing side
# ======================================================================


import cgi
import cgitb
cgitb.enable()

import string, subprocess, re

try:
  sys.path.append(os.path.dirname(sys.argv[0]))
  import config
except:
  print 'Content-type: text/plain'
  print
  print 'Cannot find config file'
  sys.exit(1)

r = re.compile('^\[GNUPG\:\] GOODSIG (\w*) (.*)')

def files():
  for f in os.listdir(config.filesdir):
    if config.version in f and (f.endswith('.tar.gz') or f.endswith('.zip') or f.endswith('.tar.bz2')):
      yield f

def ordinal(N):
  try:
    return [None, 'first', 'second', 'third', 'fourth', 'fifth', 'sixth'][N]
  except:
    # Huh?  We only have six files to sign.
    return "%dth" % N

shell_content = '''
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<title>Signature collection for Subversion $version</title>
</head>
<body style="font-size: 14pt; text-align: justify;
  background-color: #f0f0f0; padding: 0 5%%">
<p>This page is used to collect <a href="%s/list">signatures</a> for the
proposed release of Apache Subversion $version.</p>
$content
</body>
</html>
''' % os.getenv('SCRIPT_NAME')

signature_area = '''
<hr/>
<form method="post" action="%s">
<p>Paste one or more signatures in the area below:<br/>
<textarea name="signatures" rows="20" cols="80"></textarea>
</p>
<input type="submit" value="Submit" />
<p>Any text not between the <tt>BEGIN PGP SIGNATURE</tt>
and <tt>END PGP SIGNATURE</tt> lines will be ignored.</p>
</form>
<hr/>
''' % os.getenv('SCRIPT_NAME')



def split(sigs):
  lines = []
  for line in sigs.split('\n'):
    if lines or '--BEGIN' in line:
      lines.append(line)
    if '--END' in line:
      yield "\n".join(lines) + "\n"
      lines = []

def list_signatures():
  db = sqlite3.connect(os.path.join(config.sigdir, 'sigs.db'))
  template = '''
<hr/>
<p>The following signature files are available:</p>
<p>%s</p>
'''

  lines = ""
  curs = db.cursor()
  curs.execute('''SELECT filename, COUNT(*) FROM signatures
                  GROUP BY filename ORDER BY filename''')
  for filename, count in curs:
    lines += '<a href="%s/%s.asc">%s.asc</a>: %d signature%s<br/>\n' \
             % (os.getenv('SCRIPT_NAME'), filename, filename,
                count, ['s', ''][count == 1])
  return (template % lines) + signature_area

def save_valid_sig(db, filename, keyid, signature):
  db.execute('INSERT OR REPLACE INTO signatures VALUES (?,?,?);',
             (keyid, filename, buffer(signature)))
  db.commit()

  generate_asc_files(config.sigdir)

def verify_sig_for_file(signature, filename):
  args = ['gpg', '--logger-fd', '1', '--no-tty',
          '--status-fd', '2', '--verify', '-',
          os.path.join(config.filesdir, filename)]

  gpg = subprocess.Popen(args,
                         stdin=subprocess.PIPE,
                         stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE)

  gpg.stdin.write(signature)
  gpg.stdin.close()

  rc = gpg.wait()
  output = gpg.stdout.read()
  status = gpg.stderr.read()

  if rc:
    return (False, status + output)

  lines = status.split('\n')
  for line in lines:
    match = r.search(line)
    if match:
      keyid = match.group(1)
      user = match.group(2)

  return (True, (filename, keyid, user))

def verify_sig(signature):
  all_failures = ""
  for filename in files():
    (verified, result) = verify_sig_for_file(signature, filename)
    if verified:
      return (verified, result)
    else:
      all_failures += "%s:\n[[[\n%s]]]\n\n" % (filename, result)
  return (False, all_failures)

def process_sigs(signatures):
  success = '''
  <p style="color: green;">All %d signatures verified!</p>
'''
  failure = '''
  <p style="color: red;">%d of %d signatures failed to verify; details below.</p>
'''
  c_verified = '''
  <p style="color: green;">The signature is verified!</p>
  <p>Filename: <code>%s</code></p>
  <p>Key ID: <code>%s</code></p>
  <p>User: <code>%s</code></p>
  <p>This signature has been saved, and will be included as part of the
    release signatures.</p>
'''
  c_unverified = '''
  <p style="color: red;">The signature was not able to be verified!</p>
  <p>Signature: <pre>%s</pre></p>
  <p>Reason:</p><pre>%s</pre>
  <p>Please talk to the release manager if this is in error.</p>
'''

  outcomes = []
  N_sigs = 0
  N_verified = 0
  retval = ''

  # Verify
  db = sqlite3.connect(os.path.join(config.sigdir, 'sigs.db'))
  for signature in split(signatures):
    N_sigs += 1
    (verified, result) = verify_sig(signature)
    outcomes.append((verified, result))

    if verified:
      (filename, keyid, user) = result
      save_valid_sig(db, filename, keyid, signature)
      N_verified += 1

  # Output header
  if N_verified == N_sigs:
    retval += success % N_sigs
  else:
    retval += failure % (N_sigs-N_verified, N_sigs)

  # Output details
  N = 0
  for outcome in outcomes:
    N += 1
    (verified, result) = outcome
    retval += "<h1>Results for the %s signature</h1>" % ordinal(N)
    if verified:
      (filename, keyid, user) = result
      retval += c_verified % (filename, keyid[-8:], user)
    else:
      retval += c_unverified % (signature, result)

  return retval + signature_area


def cat_signatures(basename):
  # strip '.asc' extension
  assert basename[:-4] in files()

  # cat
  ascfile = os.path.join(config.sigdir, basename)
  if os.path.exists(ascfile):
    return (open(ascfile, 'r').read())

def print_content_type(mimetype):
  print "Content-Type: " + mimetype
  print

def main():
  form = cgi.FieldStorage()
  pathinfo = os.getenv('PATH_INFO')

  # default value, to be changed below
  content = signature_area

  if 'signatures' in form:
    content = process_sigs(form['signatures'].value)

  elif pathinfo and pathinfo[1:]:
    basename = pathinfo.split('/')[-1]

    if basename == 'list':
      content = list_signatures()

    elif basename[:-4] in files():
      # early exit; bypass 'content' entirely
      print_content_type('text/plain')
      print cat_signatures(basename)
      return

  # These are "global" values, not specific to our action.
  mapping = {
      'version' : config.version,
      'content' : content,
    }

  print_content_type('text/html')

  template = string.Template(shell_content)
  print template.safe_substitute(mapping)


if __name__ == '__main__':
  main()