add the rest of the stuff

This commit is contained in:
William Pitcock 2018-08-10 20:36:24 -05:00
parent df62c1353e
commit 6d234563e5
7 changed files with 514 additions and 9 deletions

View file

@ -5,10 +5,10 @@ name = "pypi"
[packages]
aiohttp = "*"
asyncio-irc = "*"
pycrypto = "*"
simplejson = "*"
pyyaml = "*"
blinker = "*"
[dev-packages]

10
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "18bbb3d2a3de6c69514d095176966458dd6b98b1d4834cee00c67aba2af5af8f"
"sha256": "7d43dd71bd5f6ab7a5e47465e091d518de798e3bc2f94b576243a38613253a4e"
},
"pipfile-spec": 6,
"requires": {
@ -45,13 +45,6 @@
"markers": "python_version >= '3.5.3'",
"version": "==3.0.0"
},
"asyncio-irc": {
"hashes": [
"sha256:65b5b74c1795ea1be61e68cfa67fd59019abafca86cd2ba5684da04573554dbc"
],
"index": "pypi",
"version": "==0.2.1"
},
"attrs": {
"hashes": [
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
@ -63,6 +56,7 @@
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"index": "pypi",
"version": "==1.4"
},
"chardet": {

View file

@ -3,6 +3,7 @@ import aiohttp.web
import logging
from . import app
from .irc import irc_bot
async def start_webserver():
@ -18,6 +19,7 @@ async def start_webserver():
def main():
loop = asyncio.get_event_loop()
asyncio.ensure_future(start_webserver())
asyncio.ensure_future(irc_bot())
loop.run_forever()

View file

@ -1,6 +1,11 @@
import aiohttp
import aiohttp.web
import logging
import uuid
import urllib.parse
import simplejson as json
import re
import cgi
from Crypto.PublicKey import RSA
from .database import DATABASE
@ -18,7 +23,13 @@ if "actorKeys" not in DATABASE:
}
PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
PUBKEY = PRIVKEY.publickey()
from . import app
from .remote_actor import fetch_actor
async def actor(request):
@ -44,3 +55,79 @@ async def actor(request):
app.router.add_get('/actor', actor)
from .http_signatures import sign_headers
async def push_message_to_actor(actor, message, our_key_id):
url = urllib.parse.urlsplit(actor['inbox'])
# XXX: Digest
data = json.dumps(message)
headers = {
'(request-target)': 'post {}'.format(url.path),
'Content-Length': str(len(data)),
'Content-Type': 'application/activity+json',
'User-Agent': 'Viera'
}
headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
async with aiohttp.ClientSession() as session:
async with session.post(actor['inbox'], data=data, headers=headers) as resp:
pass
tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
def strip_html(data):
no_tags = tag_re.sub('', data)
return cgi.escape(no_tags)
from .authreqs import check_reqs
async def handle_create(actor, data, request):
content = strip_html(data['object']['content']).split()
check_reqs(content, actor)
async def handle_follow(actor, data, request):
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"to": [actor["id"]],
# this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
"object": {
"type": "Follow",
"id": data["id"],
"object": "https://{}/actor".format(request.host),
"actor": actor["id"]
},
"id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
}
await push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host))
processors = {
'Create': handle_create,
'Follow': handle_follow
}
async def inbox(request):
data = await request.json()
actor = await fetch_actor(data["actor"])
actor_uri = 'https://{}/actor'.format(request.host)
processor = processors.get(data['type'], None)
if processor:
await processor(actor, data, request)
return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
app.router.add_post('/inbox', inbox)

59
viera/authreqs.py Normal file
View file

@ -0,0 +1,59 @@
import uuid
import logging
from collections import namedtuple
from .database import DATABASE
PendingAuth = namedtuple('PendingAuth', ['irc_nickname', 'irc_account', 'actor'])
AUTHS = DATABASE.get('auths', {})
DATABASE["auths"] = AUTHS
PENDING_AUTHS = {}
IRC_BOT = None
def check_reqs(chunks, actor):
global DATABASE
results = [x in PENDING_AUTHS for x in chunks]
logging.debug('AUTHREQ: chunks: %r, results: %r', chunks, results)
if True in results:
pending_slot = results.index(True)
pending_uuid = chunks[pending_slot]
req = PENDING_AUTHS.pop(pending_uuid)._replace(actor=actor["id"])
logging.debug("IRC BOT: %r, AUTHREQ: %r", IRC_BOT, req)
if IRC_BOT:
IRC_BOT.handle_auth_req(req)
DATABASE["auths"][req.irc_account] = req.actor
def new_auth_req(irc_nickname, irc_account):
authid = str(uuid.uuid4())
PENDING_AUTHS[authid] = PendingAuth(irc_nickname, irc_account, None)
return authid
def set_irc_bot(bot):
global IRC_BOT
IRC_BOT = bot
logging.debug("SET IRC BOT TO: %r", bot)
def check_auth(account):
return account in DATABASE["auths"]
def fetch_auth(account):
if check_auth(account):
return DATABASE["auths"][account]
return None

217
viera/irc.py Normal file
View file

@ -0,0 +1,217 @@
import asyncio
import logging
import base64
from blinker import signal
from . import CONFIG
from .irc_envelope import RFC1459Message
from .authreqs import new_auth_req, set_irc_bot, check_auth, fetch_auth
IRC_CONFIG = CONFIG.get('irc', {})
# SASL_PAYLOAD = base64.b64encode(b'\x00'.join([IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password']]))
class IRCProtocol(asyncio.Protocol):
def connection_made(self, transport):
self.pending_actions = {}
self.caps_available = []
self.caps_requested = []
self.caps_acknowledged = []
self.transport = transport
self.recv_buffer = b''
self.send_message(verb='CAP', params=['LS'])
self.send_message(verb='NICK', params=[IRC_CONFIG['nickname']])
self.send_message(verb='USER', params=[IRC_CONFIG['username'], '*', '*', IRC_CONFIG['realname']])
def data_received(self, data):
self.recv_buffer += data
recvd = self.recv_buffer.replace(b'\r', b'').split(b'\n')
self.recv_buffer = recvd.pop(-1)
[self.line_received(m) for m in recvd]
def line_received(self, data):
data = data.decode('UTF-8', 'replace').strip('\r\n')
m = RFC1459Message.from_message(data)
self.message_received(m)
def request_caps(self, caps):
caps = [cap for cap in caps if cap in self.caps_available]
caps = [cap for cap in caps if cap not in self.caps_requested]
logging.debug('IRC: requesting caps: %r', caps)
self.caps_requested += caps
self.send_message(verb='CAP', params=['REQ', ' '.join(caps)])
def end_caps(self):
self.send_message(verb='CAP', params=['END'])
def do_blind_authenticate(self, username, password):
username = username.encode('ascii')
password = password.encode('ascii')
payload = b'\x00'.join([username, username, password])
payload = base64.b64encode(payload).decode('ascii')
self.send_message(verb='AUTHENTICATE', params=['PLAIN'])
self.send_message(verb='AUTHENTICATE', params=[payload])
def handle_cap_message(self, message):
self.cap_requested = True
if message.params[1] == 'LS':
caps = message.params[2].split()
logging.debug('IRC: available caps: %r', caps)
self.caps_available += message.params[2].split()
self.request_caps(['sasl', 'extended-join', 'account-tag', 'account-notify'])
elif message.params[1] == 'ACK':
caps = message.params[2].split()
logging.debug('IRC: acknowledged caps: %r', caps)
self.caps_acknowledged += caps
if 'sasl' in self.caps_acknowledged:
self.do_blind_authenticate(IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password'])
else:
self.end_caps()
def join_channels(self):
self.send_message(verb='JOIN', params=[','.join(IRC_CONFIG['channels'])])
def say(self, target, message, verb='NOTICE'):
self.send_message(verb=verb, params=[target, message])
def invite(self, nickname):
[self.send_message(verb="INVITE", params=[nickname, chan]) for chan in IRC_CONFIG['channels']]
def voice(self, nickname):
[self.send_message(verb="MODE", params=[chan, "+v", nickname]) for chan in IRC_CONFIG['channels']]
def whois(self, nickname, chan, account):
data = fetch_auth(account)
if not data:
return
self.say(chan, '\x02{0}\x02: \x02{1}\x02'.format(nickname, data), verb='PRIVMSG')
def set_pending_action(self, nickname, action):
if nickname not in self.pending_actions:
self.pending_actions[nickname] = action
def process_pending_action(self, nickname, account=None):
if nickname not in self.pending_actions:
return
action = self.pending_actions.pop(nickname)
if action == 'voice':
self.voice(nickname)
elif action == 'invite':
self.invite(nickname)
elif 'whois' in action:
self.whois(nickname, action['whois'], account)
def handle_auth_req(self, req):
self.say(req.irc_nickname, "The actor \x02{0}\x02 is now linked to the IRC account \x02{1}\x02.".format(req.actor, req.irc_account))
self.set_pending_action(req.irc_nickname, 'voice')
self.process_pending_action(req.irc_nickname)
def pending_whois(self, nickname, pop=False):
if nickname not in self.pending_actions:
return False
data = self.pending_actions[nickname]
if isinstance(data, dict) and 'whois' in data:
return True
if pop:
self.pending_actions.pop(nickname)
def handle_whox(self, message):
nickname = message.params[1]
account = message.params[2]
if not check_auth(account) and not self.pending_whois(nickname, True):
auth = new_auth_req(nickname, account)
self.say(nickname, "Authentication is required for this action. In order to prove your identity, you need to send me a token via the fediverse.")
self.say(nickname, "On most platforms, posting like this will work: \x02@viera@viera.dereferenced.org {}\x02".format(auth))
self.say(nickname, "This token is ephemeral, so you can send it to me publicly if your platform does not support direct messages.")
else:
self.process_pending_action(nickname, account)
def fetch_account_whox(self, message):
source_nick = message.source.split('!')[0]
self.send_message(verb='WHO', params=[source_nick, "%na"])
def handle_private_message(self, message):
source_nick = message.source.split('!')[0]
if message.params[1] == 'auth':
self.fetch_account_whox(message)
elif message.params[1] in ('voice', 'invite'):
self.set_pending_action(source_nick, message.params[1])
self.fetch_account_whox(message)
def handle_public_message(self, message):
if not message.params[1].startswith(IRC_CONFIG['nickname']):
return
chunks = message.params[1].split()
if chunks[1] == 'whois':
self.set_pending_action(chunks[2], {'whois': message.params[0]})
self.send_message(verb='WHO', params=[chunks[2], "%na"])
def handle_chat_message(self, message):
if message.params[0] == IRC_CONFIG['nickname']:
self.handle_private_message(message)
else:
self.handle_public_message(message)
def handle_join(self, message):
source_nick = message.source.split('!')[0]
if check_auth(message.params[1]):
self.set_pending_action(source_nick, 'voice')
self.process_pending_action(source_nick, message.params[1])
def message_received(self, message):
if message.verb in ('PRIVMSG', 'NOTICE'):
self.handle_chat_message(message)
elif message.verb == '001':
self.join_channels()
elif message.verb == 'JOIN':
self.handle_join(message)
elif message.verb == 'CAP':
self.handle_cap_message(message)
elif message.verb == '354':
self.handle_whox(message)
elif message.verb == '433':
self.send_message(verb='NICK', params=[message.params[0] + '_'])
elif message.verb in ('900', '901', '902', '903', '904', '905', '906', '907'):
self.end_caps()
elif message.verb == 'PING':
self.send_message(verb='PONG', params=message.params)
elif message.verb in ('AUTHENTICATE',):
pass
else:
logging.debug('IRC: unhandled inbound message: %r', message)
def send_message(self, **kwargs):
m = RFC1459Message.from_data(**kwargs)
logging.debug('> %r', m)
self.transport.write(m.to_message().encode('utf-8') + b'\r\n')
async def irc_bot():
loop = asyncio.get_event_loop()
if 'host' not in IRC_CONFIG:
return
server = IRC_CONFIG['host']
port = IRC_CONFIG['port']
ssl = IRC_CONFIG['ssl']
transport, protocol = await loop.create_connection(IRCProtocol, host=server, port=port, ssl=ssl)
set_irc_bot(protocol)
logging.info('IRC bot ready.')

146
viera/irc_envelope.py Normal file
View file

@ -0,0 +1,146 @@
# irc_envelope.py
# Purpose: Conversion of RFC1459 messages to/from native objects.
#
# Copyright (c) 2014, William Pitcock <nenolod@dereferenced.org>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
from pprint import pprint
class RFC1459Message(object):
@classmethod
def from_data(cls, verb, params=None, source=None, tags=None):
o = cls()
o.verb = verb
o.tags = dict()
o.source = None
o.params = list()
if params:
o.params = params
if source:
o.source = source
if tags:
o.tags.update(**tags)
return o
@classmethod
def from_message(cls, message):
if isinstance(message, bytes):
message = message.decode('UTF-8', 'replace')
s = message.split(' ')
tags = None
if s[0].startswith('@'):
tag_str = s[0][1:].split(';')
s = s[1:]
tags = {}
for tag in tag_str:
if '=' in tag:
k, v = tag.split('=', 1)
tags[k] = v
else:
tags[tag] = True
source = None
if s[0].startswith(':'):
source = s[0][1:]
s = s[1:]
verb = s[0].upper()
original_params = s[1:]
params = []
while len(original_params):
# skip multiple spaces in middle of message, as per 1459
if original_params[0] == '' and len(original_params) > 1:
original_params.pop(0)
continue
elif original_params[0].startswith(':'):
arg = ' '.join(original_params)[1:]
params.append(arg)
break
else:
params.append(original_params.pop(0))
return cls.from_data(verb, params, source, tags)
def args_to_message(self):
base = []
for arg in self.params:
casted = str(arg)
if casted and ' ' not in casted and casted[0] != ':':
base.append(casted)
else:
base.append(':' + casted)
break
return ' '.join(base)
def to_message(self):
components = []
if self.tags:
components.append('@' + ';'.join([k + '=' + v for k, v in self.tags.items()]))
if self.source:
components.append(':' + self.source)
components.append(self.verb)
if self.params:
components.append(self.args_to_message())
return ' '.join(components)
def to_event(self):
return "rfc1459 message " + self.verb, self.__dict__
def serialize(self):
return self.__dict__
def __repr__(self):
return '[RFC1459Message: "{0}"]'.format(self.to_message())
def test_rfc1459message():
print('====== PARSER TESTS ======')
print(RFC1459Message.from_message('@foo=bar PRIVMSG kaniini :this is a test message!'))
print(RFC1459Message.from_message('@foo=bar :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!'))
print(RFC1459Message.from_message('PRIVMSG kaniini :this is a test message!'))
print(RFC1459Message.from_message(':irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!'))
print(RFC1459Message.from_message('CAPAB '))
print('====== STRUCTURE TESTS ======')
m = RFC1459Message.from_message('@foo=bar;bar=baz :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!')
pprint(m.serialize())
print('====== BUILDER TESTS ======')
data = {
'verb': 'PRIVMSG',
'params': ['kaniini', 'hello world!'],
'source': 'kaniini!~kaniini@localhost',
'tags': {'account-name': 'kaniini'},
}
m = RFC1459Message.from_data(**data)
print(m.to_message())
pprint(m.serialize())
print('====== ALL TESTS: PASSED ======')
if __name__ == '__main__':
test_rfc1459message()