From c91d0a675547e3ddde29af04e5454d00370a1082 Mon Sep 17 00:00:00 2001 From: kaniini Date: Tue, 30 Oct 2018 01:42:17 +0000 Subject: [PATCH] strip down to base AP code --- relay.yaml.example | 9 + {viera => relay}/__init__.py | 2 +- {viera => relay}/__main__.py | 0 {viera => relay}/actor.py | 31 +--- {viera => relay}/database.py | 0 {viera => relay}/http_signatures.py | 0 {viera => relay}/logging.py | 0 {viera => relay}/remote_actor.py | 0 {viera => relay}/webfinger.py | 2 +- viera.yaml.example | 53 ------ viera/authreqs.py | 72 -------- viera/irc.py | 277 ---------------------------- viera/irc_envelope.py | 146 --------------- 13 files changed, 17 insertions(+), 575 deletions(-) create mode 100644 relay.yaml.example rename {viera => relay}/__init__.py (91%) rename {viera => relay}/__main__.py (100%) rename {viera => relay}/actor.py (85%) rename {viera => relay}/database.py (100%) rename {viera => relay}/http_signatures.py (100%) rename {viera => relay}/logging.py (100%) rename {viera => relay}/remote_actor.py (100%) rename {viera => relay}/webfinger.py (92%) delete mode 100644 viera.yaml.example delete mode 100644 viera/authreqs.py delete mode 100644 viera/irc.py delete mode 100644 viera/irc_envelope.py diff --git a/relay.yaml.example b/relay.yaml.example new file mode 100644 index 0000000..3445136 --- /dev/null +++ b/relay.yaml.example @@ -0,0 +1,9 @@ +# this is the path that the object graph will get dumped to (in JSON-LD format), +# you probably shouldn't change it, but you can if you want. +db: relay.jsonld + +# this section is for ActivityPub +ap: + # this is used for generating activitypub messages, as well as instructions for + # linking AP identities. it should be an SSL-enabled domain reachable by https. + host: 'relay.pleroma.site' diff --git a/viera/__init__.py b/relay/__init__.py similarity index 91% rename from viera/__init__.py rename to relay/__init__.py index 99bd3a3..8c55bbf 100644 --- a/viera/__init__.py +++ b/relay/__init__.py @@ -8,7 +8,7 @@ import yaml def load_config(): - with open('viera.yaml') as f: + with open('relay.yaml') as f: return yaml.load(f) diff --git a/viera/__main__.py b/relay/__main__.py similarity index 100% rename from viera/__main__.py rename to relay/__main__.py diff --git a/viera/actor.py b/relay/actor.py similarity index 85% rename from viera/actor.py rename to relay/actor.py index 177da7f..72734b5 100644 --- a/viera/actor.py +++ b/relay/actor.py @@ -43,7 +43,8 @@ async def actor(request): "followers": "https://{}/followers".format(request.host), "following": "https://{}/following".format(request.host), "inbox": "https://{}/inbox".format(request.host), - "name": "Viera", + "sharedInbox": "https://{}/inbox".format(request.host), + "name": "ActivityRelay", "type": "Application", "id": "https://{}/actor".format(request.host), "publicKey": { @@ -51,8 +52,8 @@ async def actor(request): "owner": "https://{}/actor".format(request.host), "publicKeyPem": DATABASE["actorKeys"]["publicKey"] }, - "summary": "Viera, the bot", - "preferredUsername": "viera", + "summary": "ActivityRelay bot", + "preferredUsername": "relay", "url": "https://{}/actor".format(request.host) } return aiohttp.web.json_response(data) @@ -73,7 +74,7 @@ async def push_message_to_actor(actor, message, our_key_id): '(request-target)': 'post {}'.format(url.path), 'Content-Length': str(len(data)), 'Content-Type': 'application/activity+json', - 'User-Agent': 'Viera' + 'User-Agent': 'ActivityRelay' } headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id) @@ -128,28 +129,8 @@ def strip_html(data): return cgi.escape(no_tags) -from .authreqs import check_reqs, get_irc_bot - - async def handle_create(actor, data, request): - # for now, we only care about Notes - if data['object']['type'] != 'Note': - return - - # strip the HTML if present - content = strip_html(data['object']['content']).split() - - # check if the message is an authorization token for linking identities together - # if it is, then it's not a message we want to relay to IRC. - if check_reqs(content, actor): - return - - # check that the message is public before relaying - public_uri = 'https://www.w3.org/ns/activitystreams#Public' - - if public_uri in data.get('to', []) or public_uri in data.get('cc', []): - bot = get_irc_bot() - bot.relay_message(actor, data['object'], ' '.join(content)) + pass async def handle_follow(actor, data, request): diff --git a/viera/database.py b/relay/database.py similarity index 100% rename from viera/database.py rename to relay/database.py diff --git a/viera/http_signatures.py b/relay/http_signatures.py similarity index 100% rename from viera/http_signatures.py rename to relay/http_signatures.py diff --git a/viera/logging.py b/relay/logging.py similarity index 100% rename from viera/logging.py rename to relay/logging.py diff --git a/viera/remote_actor.py b/relay/remote_actor.py similarity index 100% rename from viera/remote_actor.py rename to relay/remote_actor.py diff --git a/viera/webfinger.py b/relay/webfinger.py similarity index 92% rename from viera/webfinger.py rename to relay/webfinger.py index 71e172a..e6145a0 100644 --- a/viera/webfinger.py +++ b/relay/webfinger.py @@ -5,7 +5,7 @@ from . import app async def webfinger(request): subject = request.query['resource'] - if subject != 'acct:viera@{}'.format(request.host): + if subject != 'acct:relay@{}'.format(request.host): return aiohttp.web.json_response({'error': 'user not found'}, status=404) actor_uri = "https://{}/actor".format(request.host) diff --git a/viera.yaml.example b/viera.yaml.example deleted file mode 100644 index ee1fb7f..0000000 --- a/viera.yaml.example +++ /dev/null @@ -1,53 +0,0 @@ -# this is the path that the object graph will get dumped to (in JSON-LD format), -# you probably shouldn't change it, but you can if you want. -db: viera.jsonld - -# this section configures the IRC bot -irc: - # hostname of IRC network to connect to - host: chat.freenode.net - - # port of IRC network to connect to - port: 6697 - - # whether to use SSL/TLS to connect or not - ssl: true - - # the main nickname of the bot to use - nickname: viera - - # the username of the bot to use - username: viera - - # the realname / GECOS of the bot to use - realname: Viera; https://viera.dereferenced.org - - # channels for the bot to join - channels: - - '#mychannel' - - '#myotherchannel' - - # channels for the bot to relay AP posts to - relay_channels: - - # allow any AP actor to be relayed - '#mychannel': [] - - # allow only one AP actor to be relayed - '#myotherchannel': - - 'https://example.org/~alyssa' - - # IRC services credentials. - sasl_username: viera - sasl_password: examplepass - - # IRC users with accounts linked to these AP identities may administer the bot - # (follow and unfollow commands) - privileged: - - 'https://pleroma.site/users/kaniini' - -# this section is for ActivityPub -ap: - # this is used for generating activitypub messages, as well as instructions for - # linking AP identities. it should be an SSL-enabled domain reachable by https. - host: 'viera.dereferenced.org' diff --git a/viera/authreqs.py b/viera/authreqs.py deleted file mode 100644 index 56c7fc7..0000000 --- a/viera/authreqs.py +++ /dev/null @@ -1,72 +0,0 @@ -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 - - return True in results - - -def new_auth_req(irc_nickname, irc_account): - authid = str(uuid.uuid4()) - PENDING_AUTHS[authid] = PendingAuth(irc_nickname, irc_account, None) - - return authid - - -# XXX - utter hackjob -def set_irc_bot(bot): - global IRC_BOT - - IRC_BOT = bot - logging.debug("SET IRC BOT TO: %r", bot) - - -def get_irc_bot(): - global IRC_BOT - - return IRC_BOT - - -def check_auth(account): - return account in DATABASE["auths"] - - -def fetch_auth(account): - if check_auth(account): - return DATABASE["auths"][account] - - return None - - -def drop_auth(account): - DATABASE["auths"].pop(account, None) diff --git a/viera/irc.py b/viera/irc.py deleted file mode 100644 index 01be975..0000000 --- a/viera/irc.py +++ /dev/null @@ -1,277 +0,0 @@ -import asyncio -import logging -import base64 -import html - -from blinker import signal - -from . import CONFIG -from .actor import follow_remote_actor, unfollow_remote_actor -from .irc_envelope import RFC1459Message - -from .authreqs import new_auth_req, set_irc_bot, check_auth, fetch_auth, drop_auth -IRC_CONFIG = CONFIG.get('irc', {}) -AP_CONFIG = CONFIG.get('irc', {'host': 'localhost'}) - -# 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 follow(self, nickname, actor_uri): - asyncio.ensure_future(follow_remote_actor(actor_uri)) - self.say(nickname, 'Following \x02{}\x02'.format(actor_uri)) - - def unfollow(self, nickname, actor_uri): - asyncio.ensure_future(unfollow_remote_actor(actor_uri)) - self.say(nickname, 'Unfollowing \x02{}\x02'.format(actor_uri)) - - 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 action == 'drop': - data = fetch_auth(account) - drop_auth(account) - self.say(nickname, "The association of \x02{0}\x02 with \x02{1}\x02 has been dropped.".format(account, data)) - elif 'whois' in action: - self.whois(nickname, action['whois'], account) - elif 'follow' in action: - data = fetch_auth(account) - if not data: - return - if data not in IRC_CONFIG['privileged']: - self.say(nickname, "Access denied: \x02{0}\x02 is unprivileged.".format(data)) - return - logging.info('allowed follow: %r', action['follow']) - self.follow(nickname, action['follow']) - elif 'unfollow' in action: - data = fetch_auth(account) - if not data: - return - if data not in IRC_CONFIG['privileged']: - self.say(nickname, "Access denied: \x02{0}\x02 is unprivileged.".format(data)) - return - logging.info('allowed unfollow: %r', action['unfollow']) - self.unfollow(nickname, action['unfollow']) - - 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@{1} {0}\x02".format(auth, AP_CONFIG['host'])) - 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', 'drop'): - self.set_pending_action(source_nick, message.params[1]) - self.fetch_account_whox(message) - elif message.params[1][0:6] == 'follow': - chunks = message.params[1].split() - logging.info('considering whether to follow: %r', chunks[1]) - - self.set_pending_action(source_nick, {'follow': chunks[1]}) - self.fetch_account_whox(message) - elif message.params[1][0:8] == 'unfollow': - chunks = message.params[1].split() - logging.info('considering whether to unfollow: %r', chunks[1]) - - self.set_pending_action(source_nick, {'unfollow': chunks[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') - - def relay_message(self, actor, obj, content): - fmt = "\x02{name}\x02: {content} [{url}]" - - content = html.unescape(content) - content = html.unescape(content) - msgcontent = content[0:256] - if len(content) > 256: - msgcontent += '...' - - targets = [chan for chan, actors in IRC_CONFIG['relay_channels'].items() if not actors or actor['id'] in actors] - message = fmt.format(name=actor['name'], content=msgcontent, url=obj['id']) - target = ','.join(targets) - - self.say(target, message) - - -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.') diff --git a/viera/irc_envelope.py b/viera/irc_envelope.py deleted file mode 100644 index e980eeb..0000000 --- a/viera/irc_envelope.py +++ /dev/null @@ -1,146 +0,0 @@ -# irc_envelope.py -# Purpose: Conversion of RFC1459 messages to/from native objects. -# -# Copyright (c) 2014, William Pitcock -# -# 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()