From a2f81bdad0c4a3cbc2dca4e78424030310219ba4 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Fri, 15 Jul 2022 16:15:00 -0400 Subject: Initial implementation of b4 submit This is the first rough implementation of "b4 submit". Currently implemented: - b4 submit --new : to start a new branch - b4 submit --edit-cover : to edit the cover message - b4 submit --update-trailers : to receive latest trailer updates from the mailing lists - b4 submit --send : sends the messages using existing git.sendemail configs For details, see "b4 submit --help". Signed-off-by: Konstantin Ryabitsev --- misc/send-receive.py | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++ misc/test.sqlite | Bin 0 -> 24576 bytes 2 files changed, 170 insertions(+) create mode 100644 misc/send-receive.py create mode 100644 misc/test.sqlite (limited to 'misc') diff --git a/misc/send-receive.py b/misc/send-receive.py new file mode 100644 index 0000000..7b47798 --- /dev/null +++ b/misc/send-receive.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# noinspection PyUnresolvedReferences +import falcon +import os +import logging +import json +import sqlalchemy as sa + +from nacl.signing import VerifyKey +from nacl.encoding import Base64Encoder +from nacl.exceptions import BadSignatureError + +DB_VERSION = 1 + +logger = logging.getLogger('b4-send-receive') + + +# noinspection PyBroadException, PyMethodMayBeStatic +class SendReceiveListener(object): + + def __init__(self, _engine): + self._engine = _engine + # You shouldn't use this in production + if self._engine.driver == 'pysqlite': + self._init_sa_db() + + def _init_sa_db(self): + logger.info('Creating tables') + conn = self._engine.connect() + md = sa.MetaData() + meta = sa.Table('meta', md, + sa.Column('version', sa.Integer()) + ) + auth = sa.Table('auth', md, + sa.Column('auth_id', sa.Integer(), primary_key=True), + sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()), + sa.Column('email', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('pubkey', sa.Text(), nullable=False), + ) + sa.Index('idx_email_pubkey', auth.c.pubkey, auth.c.email, unique=True) + challenge = sa.Table('challenge', md, + sa.Column('challenge_id', sa.Integer(), primary_key=True), + sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()), + sa.Column('pubkey', sa.Text(), nullable=False), + sa.Column('email', sa.Text(), nullable=False), + sa.Column('challenge', sa.Text(), nullable=False), + ) + sa.Index('idx_uniq_challenge', challenge.c.pubkey, challenge.c.email, challenge.c.challenge, unique=True) + md.create_all(self._engine) + q = sa.insert(meta).values(version=DB_VERSION) + conn.execute(q) + + def on_get(self, req, resp): # noqa + resp.status = falcon.HTTP_200 + resp.content_type = falcon.MEDIA_TEXT + resp.text = "We don't serve GETs here\n" + + def send_error(self, resp, message): + resp.status = falcon.HTTP_500 + resp.text = json.dumps({'result': 'error', 'message': message}) + + def send_success(self, resp, message): + resp.status = falcon.HTTP_200 + resp.text = json.dumps({'result': 'success', 'message': message}) + + def auth_new(self, jdata, resp): + # Is it already authorized? + conn = self._engine.connect() + md = sa.MetaData() + t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) + email = jdata.get('email') + pubkey = jdata.get('key') + q = sa.select([t_auth.c.auth_id]).where(t_auth.c.email == email, t_auth.c.pubkey == pubkey) + rp = conn.execute(q) + if len(rp.fetchall()): + self.send_error(resp, message='%s:%s is already authorized' % (email, pubkey)) + return + # delete any existing challenges for this and create a new one + t_challenge = sa.Table('challenge', md, autoload=True, autoload_with=self._engine) + q = sa.delete(t_challenge).where(t_challenge.c.email == email, t_challenge.c.pubkey == pubkey) + conn.execute(q) + # create new challenge + import uuid + cstr = str(uuid.uuid4()) + q = sa.insert(t_challenge).values(pubkey=pubkey, email=email, challenge=cstr) + conn.execute(q) + # TODO: Actual mail sending + logger.info('Challenge: %s', cstr) + self.send_success(resp, message='Challenge generated') + + def auth_verify(self, jdata, resp): + # Do we have a record for this email/challenge? + conn = self._engine.connect() + md = sa.MetaData() + t_challenge = sa.Table('challenge', md, autoload=True, autoload_with=self._engine) + email = jdata.get('email', '') + challenge = jdata.get('challenge', '') + sigdata = jdata.get('sigdata', '') + q = sa.select([t_challenge.c.pubkey]).where(t_challenge.c.email == email, t_challenge.c.challenge == challenge) + rp = conn.execute(q) + qres = rp.fetchall() + if not len(qres): + self.send_error(resp, message='No such challenge for %s' % email) + return + pubkey = qres[0][0] + vk = VerifyKey(pubkey.encode(), encoder=Base64Encoder) + try: + vk.verify(sigdata.encode(), encoder=Base64Encoder) + except BadSignatureError: + self.send_error(resp, message='Could not validate signature for %s' % email) + return + # validated at this point, so record this as valid auth + name = jdata.get('name') + t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) + q = sa.insert(t_auth).values(pubkey=pubkey, name=name, email=email) + conn.execute(q) + q = sa.delete(t_challenge).where(t_challenge.c.email == email, t_challenge.c.challenge == challenge) + conn.execute(q) + self.send_success(resp, message='Challenge verified') + + def on_post(self, req, resp): + if not req.content_length: + resp.status = falcon.HTTP_500 + resp.content_type = falcon.MEDIA_TEXT + resp.text = 'Payload required\n' + return + raw = req.bounded_stream.read() + try: + jdata = json.loads(raw) + except: + resp.status = falcon.HTTP_500 + resp.content_type = falcon.MEDIA_TEXT + resp.text = 'Failed to parse the request\n' + return + logger.info(jdata) + action = jdata.get('action') + if action == 'auth-new': + self.auth_new(jdata, resp) + if action == 'auth-verify': + self.auth_verify(jdata, resp) + else: + resp.status = falcon.HTTP_500 + resp.content_type = falcon.MEDIA_TEXT + resp.text = 'Unknown action: %s\n' % action + return + + +app = falcon.App() +dburl = os.getenv('DB_URL', 'sqlite:///:memory:') +engine = sa.create_engine(dburl) +srl = SendReceiveListener(engine) +mp = os.getenv('MOUNTPOINT', '/_b4_submit') +app.add_route(mp, srl) + + +if __name__ == '__main__': + from wsgiref.simple_server import make_server + logger.setLevel(logging.DEBUG) + ch = logging.StreamHandler() + formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + ch.setLevel(logging.DEBUG) + logger.addHandler(ch) + + with make_server('', 8000, app) as httpd: + logger.info('Serving on port 8000...') + + # Serve until process is killed + httpd.serve_forever() diff --git a/misc/test.sqlite b/misc/test.sqlite new file mode 100644 index 0000000..fc7a99b Binary files /dev/null and b/misc/test.sqlite differ -- cgit v1.2.3