def __init__(self, config): self.work_dir = config['work_dir'] self.listen_on = (config['http_host'], int(config['http_port'])) self.config = config self.event_loop = SelectLoop() self.DEBUG = self.event_loop.DEBUG = self.config.get('debug', False) self.config_irc = config['irc'] self.networks = {}
class Mutiny(): """The main Mutiny class.""" def __init__(self, config): self.work_dir = config['work_dir'] self.listen_on = (config['http_host'], int(config['http_port'])) self.config = config self.event_loop = SelectLoop() self.DEBUG = self.event_loop.DEBUG = self.config.get('debug', False) self.config_irc = config['irc'] self.networks = {} def parse_spec(self, server): if ':' in server: proto, server = server.split(':', 1) if proto in ('ircs', 'sirc'): proto = 'ssl' else: proto = 'irc' if ':' in server: server, port = server.strip('/').rsplit(':', 1) else: server, port = server.strip('/'), (proto == 'ssl') and 6697 or 6667 return proto, str(server), int(port) def start(self): if not os.path.exists(self.work_dir): os.mkdir(self.work_dir) for network, settings in self.config['irc'].iteritems(): if settings['enable']: bot = self.networks[network] = IrcBot() bot.irc_nickname(settings['nickname']) bot.irc_channels(settings['channels'].keys()) self.connect_client(network, bot) self.event_loop.start() def connect_client(self, network, client, server_spec=None): if not server_spec: server_spec = self.config['irc'][network]['servers'][0] proto, server, port = self.parse_spec(server_spec) print 'Connecting to %-15s %s://%s:%d/' % (network, proto, server, port) client.server = server Connect(proto, server, port, *self.callbacks(network, client)).start() def stop(self): self.event_loop.stop() def failed(self, network, bot, socket): # FIXME: This is rather dumb, we should retry. self.stop() raise def callbacks(self, network, bot): def ok(sockfd): return self.connected(network, bot, sockfd) def fail(sockfd): return self.failed(network, bot, sockfd) return ok, fail def connected(self, network, bot, sockfd): print 'Connected to %s!' % (network) bot.process_connect(lambda d: self.event_loop.sendall(sockfd, d)) self.event_loop.add(sockfd, bot) def load_template(self, name, config={}, max_size=102400): sv = {} for setting, default in [('lang', 'en'), ('skin', 'default'), ('templates', '.SELF/html')]: sv[setting] = [ config.get(setting, self.config.get(setting, default)) ] if default not in sv[setting]: sv[setting].append(default) tried = [] for path in sv['templates']: for skin in sv['skin']: for lang in sv['lang']: fp = os.path.join( path, os.path.join(skin, os.path.join(lang, name))) tried.append(fp) if os.path.exists(fp): fd = open(fp, 'rb') return open(fp).read(max_size) raise NotFoundException('Not found: %s, tried: %s' % (name, tried)) def fixup_channel(self, channel): if not channel[0] in ('!', '&'): channel = '#' + channel return channel def renderChannelList(self): html = [] networks = sorted([(n, self.config_irc[n]) for n in self.config_irc if self.config_irc[n]['enable']]) for net_id, network in networks: if len(networks) > 1: html.append('<li class="network">%s<ul>' % network.get('description', net_id)) channels = network['channels'] for ch_id, channel in sorted([(i, c) for i, c in channels.items()]): if 'unlisted' not in channel.get('access', 'open'): html.append(('<li><a href="/join/%s/%s">%s</a></li>') % (net_id, ch_id.replace('#', ''), channel.get('description', ch_id))) if len(networks) > 1: html.append('</ul></li>') if html: html[0:0] = ['<ul class="channel_list">'] html.append('</ul>') else: html = ['<ul class="channel_list_empty"><i>None, sorry</i></ul>'] return ''.join(html) def handleHttpRequest(self, req, scheme, netloc, path, params, query, frag, qs, posted, cookies, user=None): if path.startswith('/'): path = path[1:] path_url = path path = urllib.unquote(path).decode('utf-8') # Defaults, will probably be overridden by individual response pages headers = [] cachectrl = 'max-age=3600, public' mime_type = 'text/html' code = 200 data = None # Clear any expired cookies, update others, record credentials credentials = {} for c, v in cookies.items(): try: prefix, network = c.split('-', 1) muid = v.value.split(',')[0] if (not network in self.networks or muid not in self.networks[network].users): req.setCookie(c, '', delete=True) else: log_id = v.value.split(',', 1)[1] user = self.networks[network].users[muid] if log_id != user.log_id: req.setCookie(c, '%s,%s' % (muid, user.log_id)) else: user.seen = time.time() credentials[network] = user except (ValueError, KeyError): pass # Shared values for rendering templates page_url = req.absolute_url() page_prefix = '/'.join(page_url.split('/', 4)[:3]) host = req.header('Host', 'unknown').lower() template = '' page = { 'templates': os.path.join(self.work_dir, 'html'), 'version': VERSION, 'skin': host, 'host': host, 'page_path': '/' + path_url, 'page_url': page_url, } # Get the actual content. try: if req.command == 'GET': if path == '': template = self.load_template('index.html', config=page) page.update( {'linked_channel_list': self.renderChannelList()}) elif path.startswith('_api/v1/'): return self.handleApiRequest(req, path, qs, posted) elif path.startswith('join/'): template, page = self.prepareChannelPage( path, page, credentials) elif (path.startswith('_skin/') or path in ('favicon.ico', )): template = self.load_template(path.split('/')[-1], config=page) mime_type = HttpdLite.GuessMimeType(path) if mime_type != 'application/octet-stream' and path.endswith( '.gz'): headers.append(('Content-Encoding', 'gzip')) elif path.startswith('_authlite/') and req.auth_info: return self.handleUserLogin(req, page_prefix, path, qs) elif path == 'robots.txt': # FIXME: Have an explicit search-engine policy in settings? raise NotFoundException('FIXME') else: raise NotFoundException() elif req.command == 'POST': if path.startswith('_api/v1/'): return self.handleApiRequest(req, path, qs, posted) else: raise NotFoundException() except NotFoundException: cachectrl, code, data = 'no-cache', 404, '<h1>404 Not found</h1>\n' if not data: if '__Mutiny_Template__' in template: data = (template.decode('utf-8') % page).encode('utf-8') cachectrl = 'no-cache' else: data = template return req.sendResponse(data, code=code, mimetype=mime_type, header_list=headers, cachectrl=cachectrl) def get_channel_from_path(self, path): join, network, channel = path.split('/') if join != 'join': raise ValueError('Invalid path') channel = self.fixup_channel(channel) return network, channel VALID_CHARS = 'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' TRANSLATE = [ # Some things we map to underscores. (u'\n\r\t\'\"&-', '_' * 100), # Icelandic map (u'\xe1\xe9\xed\xf3\xfa\xfd\xfe\xe6\xf6\xf0' + u'\xc1\xc9\xcd\xd3\xda\xdd\xde\xc6\xd6\xd0', [ 'a', 'e', 'i', 'o', 'u', 'y', 'th', 'a', 'o', 'd', 'A', 'E', 'I', 'O', 'U', 'Y', 'Th', 'A', 'O', 'D' ]) # Add more maps here, unlisted chars get stripped. :-) ] def dumb_down(self, string): for src, dst in self.TRANSLATE: output = [] for c in string: try: output.append(dst[src.index(c)]) except ValueError: output.append(c) string = ''.join(output) return ''.join(i for i in string if i in self.VALID_CHARS) def handleUserLogin(self, req, page_prefix, path, qs): state = qs.get('state', ['/'])[0] network, channel = self.get_channel_from_path(state[1:]) provider, token = req.auth_info profile = {'source': provider, 'home': 'The Internet'} # First, pull details from their on-line profile, if possible... try: if provider == 'Facebook': fbp = req.server.auth_handler.getFacebookProfile(token) profile.update({ 'name': fbp.get('name'), 'home': fbp.get('hometown', {}).get('name', 'The Internet'), 'uid': 'fb%s' % fbp.get('id', ''), 'pic': 'https://graph.facebook.com/%s/picture' % fbp.get('id', ''), 'url': 'https://www.facebook.com/%s' % fbp.get('username', fbp.get('id', '')) }) elif provider == 'Google': profile = req.server.auth_handler.getGoogleProfile(token) else: print '*** Not sure how to get profiles from %s' % provider except (IOError, OSError): # Network problems...? pass # Create a nick-name for them nickname = profile.get('name', 'Guest %x' % random.randint(0, 10000)) while (len(nickname) > 15 and ' ' in nickname): nickname = nickname.rsplit(' ', 1)[0] profile['nick'] = self.dumb_down(nickname) # Create an IRC client, start the connection. client = IrcClient().irc_profile(profile).irc_channels([channel]) self.networks[network].users[client.uid] = client self.connect_client(network, client) # Finally, set a cookie with their client's UID. req.setCookie('muid-%s' % network, '%s,pending' % client.uid) print 'Logged in: %s' % HttpdLite.json_encode(profile, indent=2) return req.sendRedirect(page_prefix + state) def prepareChannelPage(self, path, page, credentials): network, channel = self.get_channel_from_path(path) nw_channels = self.config_irc.get(network, {}).get('channels', []) if channel in nw_channels: uids = 'anon' if network in credentials: user = credentials[network] uids = '%s,%s' % (user.uid, user.log_id) info = nw_channels[channel] page.update({ 'network': network, 'network_desc': self.config_irc[network].get('description', network), 'network_server': self.networks[network].server, 'uids': uids, 'channel': channel, 'channel_desc': info.get('description', channel), 'channel_access': info.get('access', 'open').replace(',', ' '), 'logged_in': 'no', 'log_status': 'off', 'log_not': 'not ', 'log_url': '/', }) template = self.load_template('channel.html', config=page) return template, page else: raise NotFoundException() CORS_HEADERS = [('Access-Control-Allow-Origin', '*'), ('Access-Control-Allow-Methods', 'GET, POST'), ('Access-Control-Allow-Headers', 'content-length, authorization')] def handleApiRequest(self, req, path, qs, posted): api, v1, network, muid, channel = path.split('/') if muid == 'anon': user = None else: user = self.networks[network].users[muid.split(',')[0]] headers = self.CORS_HEADERS[:] method = (posted or qs).get('a', qs.get('a'))[0] mime_type, data = getattr(self, 'api_%s' % method)( network, user, self.fixup_channel(channel), req, qs, posted) return req.sendResponse(data, mimetype=mime_type, header_list=headers, cachectrl='no-cache') def api_log(self, network, user, channel, req, qs, posted): # FIXME: Choose between bots based on network grep = qs.get('grep', [''])[0] after = qs.get('seen', [None])[0] limit = int(qs.get('limit', [0])[0]) timeout = int(qs.get('timeout', [0])[0]) if timeout: timeout += time.time() bot = self.networks[network] rules = bot.irc_parsed_mode(channel) if (rules.get('secret', False) or rules.get('key', False) or rules.get('invite_only', False)): if not (user and channel in user['channels']): # This will hide the channel key and other parameters for r in rules: if rules[r]: rules[r] = True rules['event'] = 'pleasejoin' return 'application/json', HttpdLite.json_encode( [[get_timed_uid(), rules]]) data = [] try: while not data: data = bot.irc_channel_log(channel) if after or grep: data = [ x for x in data if (x[0] > after) and ( not grep or grep in x[1]['nick'].lower() or grep in x[1].get('text', '')) ] if timeout and not data: cond = threading.Condition() ev = self.event_loop.add_sleeper(timeout, cond, 'API request') bot.irc_watch_channel(channel, ev) cond.acquire() cond.wait() cond.release() self.event_loop.remove_sleeper(ev) if time.time() >= timeout: break except SelectAborted: pass if limit: data = data[-limit:] return 'application/json', HttpdLite.json_encode(data) def api_logout(self, network, user, channel, req, qs, posted): del self.networks[network].users[user.uid] req.setCookie('muid-%s' % network, '', delete=True) sockfd = self.event_loop.fds_by_uid[user.uid] self.event_loop.sendall(sockfd, 'QUIT :Logged off\r\n') return 'application/json', HttpdLite.json_encode(['ok']) def api_say(self, network, user, channel, req, qs, posted): sockfd = self.event_loop.fds_by_uid[user.uid] privmsg = 'PRIVMSG %s :%s\r\n' % (channel, posted['msg'][0].decode('utf-8')) self.event_loop.sendall(sockfd, privmsg.encode('utf-8')) return 'application/json', HttpdLite.json_encode(['ok'])
class Mutiny(): """The main Mutiny class.""" def __init__(self, config): self.work_dir = config['work_dir'] self.listen_on = (config['http_host'], int(config['http_port'])) self.config = config self.event_loop = SelectLoop() self.DEBUG = self.event_loop.DEBUG = self.config.get('debug', False) self.config_irc = config['irc'] self.networks = {} def parse_spec(self, server): if ':' in server: proto, server = server.split(':', 1) if proto in ('ircs', 'sirc'): proto = 'ssl' else: proto = 'irc' if ':' in server: server, port = server.strip('/').rsplit(':', 1) else: server, port = server.strip('/'), (proto == 'ssl') and 6697 or 6667 return proto, str(server), int(port) def start(self): if not os.path.exists(self.work_dir): os.mkdir(self.work_dir) for network, settings in self.config['irc'].iteritems(): if settings['enable']: bot = self.networks[network] = IrcBot() bot.irc_nickname(settings['nickname']) bot.irc_channels(settings['channels'].keys()) self.connect_client(network, bot) self.event_loop.start() def connect_client(self, network, client, server_spec=None): if not server_spec: server_spec = self.config['irc'][network]['servers'][0] proto, server, port = self.parse_spec(server_spec) print 'Connecting to %-15s %s://%s:%d/' % (network, proto, server, port) client.server = server Connect(proto, server, port, *self.callbacks(network, client)).start() def stop(self): self.event_loop.stop() def failed(self, network, bot, socket): # FIXME: This is rather dumb, we should retry. self.stop() raise def callbacks(self, network, bot): def ok(sockfd): return self.connected(network, bot, sockfd) def fail(sockfd): return self.failed(network, bot, sockfd) return ok, fail def connected(self, network, bot, sockfd): print 'Connected to %s!' % (network) bot.process_connect(lambda d: self.event_loop.sendall(sockfd, d)) self.event_loop.add(sockfd, bot) def load_template(self, name, config={}, max_size=102400): sv = {} for setting, default in [('lang', 'en'), ('skin', 'default'), ('templates', '.SELF/html')]: sv[setting] = [config.get(setting, self.config.get(setting, default))] if default not in sv[setting]: sv[setting].append(default) tried = [] for path in sv['templates']: for skin in sv['skin']: for lang in sv['lang']: fp = os.path.join(path, os.path.join(skin, os.path.join(lang, name))) tried.append(fp) if os.path.exists(fp): fd = open(fp, 'rb') return open(fp).read(max_size) raise NotFoundException('Not found: %s, tried: %s' % (name, tried)) def fixup_channel(self, channel): if not channel[0] in ('!', '&'): channel = '#' + channel return channel def renderChannelList(self): html = [] networks = sorted([(n, self.config_irc[n]) for n in self.config_irc if self.config_irc[n]['enable']]) for net_id, network in networks: if len(networks) > 1: html.append('<li class="network">%s<ul>' % network.get('description', net_id)) channels = network['channels'] for ch_id, channel in sorted([(i, c) for i, c in channels.items()]): if 'unlisted' not in channel.get('access', 'open'): html.append(('<li><a href="/join/%s/%s">%s</a></li>' ) % (net_id, ch_id.replace('#', ''), channel.get('description', ch_id))) if len(networks) > 1: html.append('</ul></li>') if html: html[0:0] = ['<ul class="channel_list">'] html.append('</ul>') else: html = ['<ul class="channel_list_empty"><i>None, sorry</i></ul>'] return ''.join(html) def handleHttpRequest(self, req, scheme, netloc, path, params, query, frag, qs, posted, cookies, user=None): if path.startswith('/'): path = path[1:] path_url = path path = urllib.unquote(path).decode('utf-8') # Defaults, will probably be overridden by individual response pages headers = [] cachectrl = 'max-age=3600, public' mime_type = 'text/html' code = 200 data = None # Clear any expired cookies, update others, record credentials credentials = {} for c, v in cookies.items(): try: prefix, network = c.split('-', 1) muid = v.value.split(',')[0] if (not network in self.networks or muid not in self.networks[network].users): req.setCookie(c, '', delete=True) else: log_id = v.value.split(',', 1)[1] user = self.networks[network].users[muid] if log_id != user.log_id: req.setCookie(c, '%s,%s' % (muid, user.log_id)) else: user.seen = time.time() credentials[network] = user except (ValueError, KeyError): pass # Shared values for rendering templates page_url = req.absolute_url() page_prefix = '/'.join(page_url.split('/', 4)[:3]) host = req.header('Host', 'unknown').lower() template = '' page = { 'templates': os.path.join(self.work_dir, 'html'), 'version': VERSION, 'skin': host, 'host': host, 'page_path': '/'+path_url, 'page_url': page_url, } # Get the actual content. try: if req.command == 'GET': if path == '': template = self.load_template('index.html', config=page) page.update({ 'linked_channel_list': self.renderChannelList() }) elif path.startswith('_api/v1/'): return self.handleApiRequest(req, path, qs, posted) elif path.startswith('join/'): template, page = self.prepareChannelPage(path, page, credentials) elif (path.startswith('_skin/') or path in ('favicon.ico', )): template = self.load_template(path.split('/')[-1], config=page) mime_type = HttpdLite.GuessMimeType(path) if mime_type != 'application/octet-stream' and path.endswith('.gz'): headers.append(('Content-Encoding', 'gzip')) elif path.startswith('_authlite/') and req.auth_info: return self.handleUserLogin(req, page_prefix, path, qs) elif path == 'robots.txt': # FIXME: Have an explicit search-engine policy in settings? raise NotFoundException('FIXME') else: raise NotFoundException() elif req.command == 'POST': if path.startswith('_api/v1/'): return self.handleApiRequest(req, path, qs, posted) else: raise NotFoundException() except NotFoundException: cachectrl, code, data = 'no-cache', 404, '<h1>404 Not found</h1>\n' if not data: if '__Mutiny_Template__' in template: data = (template.decode('utf-8') % page).encode('utf-8') cachectrl = 'no-cache' else: data = template return req.sendResponse(data, code=code, mimetype=mime_type, header_list=headers, cachectrl=cachectrl) def get_channel_from_path(self, path): join, network, channel = path.split('/') if join != 'join': raise ValueError('Invalid path') channel = self.fixup_channel(channel) return network, channel VALID_CHARS = 'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' TRANSLATE = [ # Some things we map to underscores. (u'\n\r\t\'\"&-', '_' * 100), # Icelandic map (u'\xe1\xe9\xed\xf3\xfa\xfd\xfe\xe6\xf6\xf0' + u'\xc1\xc9\xcd\xd3\xda\xdd\xde\xc6\xd6\xd0', ['a', 'e', 'i', 'o', 'u', 'y', 'th', 'a', 'o', 'd', 'A', 'E', 'I', 'O', 'U', 'Y', 'Th', 'A', 'O', 'D']) # Add more maps here, unlisted chars get stripped. :-) ] def dumb_down(self, string): for src, dst in self.TRANSLATE: output = [] for c in string: try: output.append(dst[src.index(c)]) except ValueError: output.append(c) string = ''.join(output) return ''.join(i for i in string if i in self.VALID_CHARS) def handleUserLogin(self, req, page_prefix, path, qs): state = qs.get('state', ['/'])[0] network, channel = self.get_channel_from_path(state[1:]) provider, token = req.auth_info profile = { 'source': provider, 'home': 'The Internet' } # First, pull details from their on-line profile, if possible... try: if provider == 'Facebook': fbp = req.server.auth_handler.getFacebookProfile(token) profile.update({ 'name': fbp.get('name'), 'home': fbp.get('hometown', {}).get('name', 'The Internet'), 'uid': 'fb%s' % fbp.get('id', ''), 'pic': 'https://graph.facebook.com/%s/picture' % fbp.get('id', ''), 'url': 'https://www.facebook.com/%s' % fbp.get('username', fbp.get('id', '')) }) elif provider == 'Google': profile = req.server.auth_handler.getGoogleProfile(token) else: print '*** Not sure how to get profiles from %s' % provider except (IOError, OSError): # Network problems...? pass # Create a nick-name for them nickname = profile.get('name', 'Guest %x' % random.randint(0, 10000)) while (len(nickname) > 15 and ' ' in nickname): nickname = nickname.rsplit(' ', 1)[0] profile['nick'] = self.dumb_down(nickname) # Create an IRC client, start the connection. client = IrcClient().irc_profile(profile).irc_channels([channel]) self.networks[network].users[client.uid] = client self.connect_client(network, client) # Finally, set a cookie with their client's UID. req.setCookie('muid-%s' % network, '%s,pending' % client.uid) print 'Logged in: %s' % HttpdLite.json_encode(profile, indent=2) return req.sendRedirect(page_prefix + state) def prepareChannelPage(self, path, page, credentials): network, channel = self.get_channel_from_path(path) nw_channels = self.config_irc.get(network, {}).get('channels', []) if channel in nw_channels: uids = 'anon' if network in credentials: user = credentials[network] uids = '%s,%s' % (user.uid, user.log_id) info = nw_channels[channel] page.update({ 'network': network, 'network_desc': self.config_irc[network].get('description', network), 'network_server': self.networks[network].server, 'uids': uids, 'channel': channel, 'channel_desc': info.get('description', channel), 'channel_access': info.get('access', 'open').replace(',', ' '), 'logged_in': 'no', 'log_status': 'off', 'log_not': 'not ', 'log_url': '/', }) template = self.load_template('channel.html', config=page) return template, page else: raise NotFoundException() CORS_HEADERS = [ ('Access-Control-Allow-Origin', '*'), ('Access-Control-Allow-Methods', 'GET, POST'), ('Access-Control-Allow-Headers', 'content-length, authorization') ] def handleApiRequest(self, req, path, qs, posted): api, v1, network, muid, channel = path.split('/') if muid == 'anon': user = None else: user = self.networks[network].users[muid.split(',')[0]] headers = self.CORS_HEADERS[:] method = (posted or qs).get('a', qs.get('a'))[0] mime_type, data = getattr(self, 'api_%s' % method )(network, user, self.fixup_channel(channel), req, qs, posted) return req.sendResponse(data, mimetype=mime_type, header_list=headers, cachectrl='no-cache') def api_log(self, network, user, channel, req, qs, posted): # FIXME: Choose between bots based on network grep = qs.get('grep', [''])[0] after = qs.get('seen', [None])[0] limit = int(qs.get('limit', [0])[0]) timeout = int(qs.get('timeout', [0])[0]) if timeout: timeout += time.time() bot = self.networks[network] rules = bot.irc_parsed_mode(channel) if (rules.get('secret', False) or rules.get('key', False) or rules.get('invite_only', False)): if not (user and channel in user['channels']): # This will hide the channel key and other parameters for r in rules: if rules[r]: rules[r] = True rules['event'] = 'pleasejoin' return 'application/json', HttpdLite.json_encode([ [get_timed_uid(), rules] ]) data = [] try: while not data: data = bot.irc_channel_log(channel) if after or grep: data = [x for x in data if (x[0] > after) and (not grep or grep in x[1]['nick'].lower() or grep in x[1].get('text', ''))] if timeout and not data: cond = threading.Condition() ev = self.event_loop.add_sleeper(timeout, cond, 'API request') bot.irc_watch_channel(channel, ev) cond.acquire() cond.wait() cond.release() self.event_loop.remove_sleeper(ev) if time.time() >= timeout: break except SelectAborted: pass if limit: data = data[-limit:] return 'application/json', HttpdLite.json_encode(data) def api_logout(self, network, user, channel, req, qs, posted): del self.networks[network].users[user.uid] req.setCookie('muid-%s' % network, '', delete=True) sockfd = self.event_loop.fds_by_uid[user.uid] self.event_loop.sendall(sockfd, 'QUIT :Logged off\r\n') return 'application/json', HttpdLite.json_encode(['ok']) def api_say(self, network, user, channel, req, qs, posted): sockfd = self.event_loop.fds_by_uid[user.uid] privmsg = 'PRIVMSG %s :%s\r\n' % (channel, posted['msg'][0].decode('utf-8')) self.event_loop.sendall(sockfd, privmsg.encode('utf-8')) return 'application/json', HttpdLite.json_encode(['ok'])