From 7956d7edafc804c7fa39019959a42912be19c3fc Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 13:50:34 -0400 Subject: [PATCH 01/11] Splitting config.py into three files. --- earwigbot/{config.py => config/__init__.py} | 98 ++++------------------------- earwigbot/config/formatter.py | 51 +++++++++++++++ earwigbot/config/node.py | 73 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 87 deletions(-) rename earwigbot/{config.py => config/__init__.py} (81%) create mode 100644 earwigbot/config/formatter.py create mode 100644 earwigbot/config/node.py diff --git a/earwigbot/config.py b/earwigbot/config/__init__.py similarity index 81% rename from earwigbot/config.py rename to earwigbot/config/__init__.py index df19d6b..b491b76 100644 --- a/earwigbot/config.py +++ b/earwigbot/config/__init__.py @@ -41,6 +41,8 @@ try: except ImportError: yaml = None +from earwigbot.config.formatter import BotFormatter +from earwigbot.config.node import ConfigNode from earwigbot.exceptions import NoConfigError __all__ = ["BotConfig"] @@ -80,12 +82,12 @@ class BotConfig(object): self._decryption_cipher = None self._data = None - self._components = _ConfigNode() - self._wiki = _ConfigNode() - self._irc = _ConfigNode() - self._commands = _ConfigNode() - self._tasks = _ConfigNode() - self._metadata = _ConfigNode() + self._components = ConfigNode() + self._wiki = ConfigNode() + self._irc = ConfigNode() + self._commands = ConfigNode() + self._tasks = ConfigNode() + self._metadata = ConfigNode() self._nodes = [self._components, self._wiki, self._irc, self._commands, self._tasks, self._metadata] @@ -123,8 +125,8 @@ class BotConfig(object): logger = logging.getLogger("earwigbot") logger.handlers = [] # Remove any handlers already attached to us logger.setLevel(logging.DEBUG) - color_formatter = _BotFormatter(color=True) - formatter = _BotFormatter() + color_formatter = BotFormatter(color=True) + formatter = BotFormatter() if self.metadata.get("enableLogging"): hand = logging.handlers.TimedRotatingFileHandler @@ -253,7 +255,7 @@ class BotConfig(object): exit. Data from the config file is stored in six - :py:class:`~earwigbot.config._ConfigNode`\ s (:py:attr:`components`, + :py:class:`~earwigbot.config.ConfigNode`\ s (:py:attr:`components`, :py:attr:`wiki`, :py:attr:`irc`, :py:attr:`commands`, :py:attr:`tasks`, :py:attr:`metadata`) for easy access (as well as the lower-level :py:attr:`data` attribute). If passwords are encrypted, we'll use @@ -342,81 +344,3 @@ class BotConfig(object): pass return tasks - - -class _ConfigNode(object): - def __iter__(self): - for key in self.__dict__: - yield key - - def __getitem__(self, item): - return self.__dict__.__getitem__(item) - - def _dump(self): - data = self.__dict__.copy() - for key, val in data.iteritems(): - if isinstance(val, _ConfigNode): - data[key] = val._dump() - return data - - def _load(self, data): - self.__dict__ = data.copy() - - def _decrypt(self, cipher, intermediates, item): - base = self.__dict__ - for inter in intermediates: - try: - base = base[inter] - except KeyError: - return - if item in base: - ciphertext = base[item].decode("hex") - base[item] = cipher.decrypt(ciphertext).rstrip("\x00") - - def get(self, *args, **kwargs): - return self.__dict__.get(*args, **kwargs) - - def keys(self): - return self.__dict__.keys() - - def values(self): - return self.__dict__.values() - - def items(self): - return self.__dict__.items() - - def iterkeys(self): - return self.__dict__.iterkeys() - - def itervalues(self): - return self.__dict__.itervalues() - - def iteritems(self): - return self.__dict__.iteritems() - - -class _BotFormatter(logging.Formatter): - def __init__(self, color=False): - self._format = super(_BotFormatter, self).format - if color: - fmt = "[%(asctime)s %(lvl)s] %(name)s: %(message)s" - self.format = lambda record: self._format(self.format_color(record)) - else: - fmt = "[%(asctime)s %(levelname)-8s] %(name)s: %(message)s" - self.format = self._format - datefmt = "%Y-%m-%d %H:%M:%S" - super(_BotFormatter, self).__init__(fmt=fmt, datefmt=datefmt) - - def format_color(self, record): - l = record.levelname.ljust(8) - if record.levelno == logging.DEBUG: - record.lvl = l.join(("\x1b[34m", "\x1b[0m")) # Blue - if record.levelno == logging.INFO: - record.lvl = l.join(("\x1b[32m", "\x1b[0m")) # Green - if record.levelno == logging.WARNING: - record.lvl = l.join(("\x1b[33m", "\x1b[0m")) # Yellow - if record.levelno == logging.ERROR: - record.lvl = l.join(("\x1b[31m", "\x1b[0m")) # Red - if record.levelno == logging.CRITICAL: - record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red - return record diff --git a/earwigbot/config/formatter.py b/earwigbot/config/formatter.py new file mode 100644 index 0000000..561db21 --- /dev/null +++ b/earwigbot/config/formatter.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging + +__all__ = ["BotFormatter"] + +class BotFormatter(logging.Formatter): + def __init__(self, color=False): + self._format = super(BotFormatter, self).format + if color: + fmt = "[%(asctime)s %(lvl)s] %(name)s: %(message)s" + self.format = lambda rec: self._format(self.format_color(rec)) + else: + fmt = "[%(asctime)s %(levelname)-8s] %(name)s: %(message)s" + self.format = self._format + datefmt = "%Y-%m-%d %H:%M:%S" + super(BotFormatter, self).__init__(fmt=fmt, datefmt=datefmt) + + def format_color(self, record): + l = record.levelname.ljust(8) + if record.levelno == logging.DEBUG: + record.lvl = l.join(("\x1b[34m", "\x1b[0m")) # Blue + if record.levelno == logging.INFO: + record.lvl = l.join(("\x1b[32m", "\x1b[0m")) # Green + if record.levelno == logging.WARNING: + record.lvl = l.join(("\x1b[33m", "\x1b[0m")) # Yellow + if record.levelno == logging.ERROR: + record.lvl = l.join(("\x1b[31m", "\x1b[0m")) # Red + if record.levelno == logging.CRITICAL: + record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red + return record diff --git a/earwigbot/config/node.py b/earwigbot/config/node.py new file mode 100644 index 0000000..345e181 --- /dev/null +++ b/earwigbot/config/node.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +__all__ = ["ConfigNode"] + +class ConfigNode(object): + def __iter__(self): + for key in self.__dict__: + yield key + + def __getitem__(self, item): + return self.__dict__.__getitem__(item) + + def _dump(self): + data = self.__dict__.copy() + for key, val in data.iteritems(): + if isinstance(val, ConfigNode): + data[key] = val._dump() + return data + + def _load(self, data): + self.__dict__ = data.copy() + + def _decrypt(self, cipher, intermediates, item): + base = self.__dict__ + for inter in intermediates: + try: + base = base[inter] + except KeyError: + return + if item in base: + ciphertext = base[item].decode("hex") + base[item] = cipher.decrypt(ciphertext).rstrip("\x00") + + def get(self, *args, **kwargs): + return self.__dict__.get(*args, **kwargs) + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + + def items(self): + return self.__dict__.items() + + def iterkeys(self): + return self.__dict__.iterkeys() + + def itervalues(self): + return self.__dict__.itervalues() + + def iteritems(self): + return self.__dict__.iteritems() From 3cb89711f8ba83a7e8fa24aebd308f889496eab9 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 13:57:32 -0400 Subject: [PATCH 02/11] Be slightly less stupid about ConfigNodes --- earwigbot/config/node.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/earwigbot/config/node.py b/earwigbot/config/node.py index 345e181..a5746e2 100644 --- a/earwigbot/config/node.py +++ b/earwigbot/config/node.py @@ -23,25 +23,37 @@ __all__ = ["ConfigNode"] class ConfigNode(object): + def __init__(self): + self._data = {} + def __iter__(self): - for key in self.__dict__: + for key in self._data: yield key - def __getitem__(self, item): - return self.__dict__.__getitem__(item) + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, item): + self._data[key] = item + + def __getattr__(self, key): + return self._data[key] + + def __setattr__(self, key, item): + self._data[key] = item def _dump(self): - data = self.__dict__.copy() + data = self._data.copy() for key, val in data.iteritems(): if isinstance(val, ConfigNode): data[key] = val._dump() return data def _load(self, data): - self.__dict__ = data.copy() + self._data = data.copy() def _decrypt(self, cipher, intermediates, item): - base = self.__dict__ + base = self._data for inter in intermediates: try: base = base[inter] @@ -52,22 +64,22 @@ class ConfigNode(object): base[item] = cipher.decrypt(ciphertext).rstrip("\x00") def get(self, *args, **kwargs): - return self.__dict__.get(*args, **kwargs) + return self._data.get(*args, **kwargs) def keys(self): - return self.__dict__.keys() + return self._data.keys() def values(self): - return self.__dict__.values() + return self._data.values() def items(self): - return self.__dict__.items() + return self._data.items() def iterkeys(self): - return self.__dict__.iterkeys() + return self._data.iterkeys() def itervalues(self): - return self.__dict__.itervalues() + return self._data.itervalues() def iteritems(self): return self.__dict__.iteritems() From 12fb4f9520991c6cf0a5e31594bd1d709a958540 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 14:36:09 -0400 Subject: [PATCH 03/11] Starting work on PermissionsDB. --- earwigbot/config/__init__.py | 11 +++++-- earwigbot/config/node.py | 18 +++++++++-- earwigbot/config/permissions.py | 68 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 earwigbot/config/permissions.py diff --git a/earwigbot/config/__init__.py b/earwigbot/config/__init__.py index b491b76..be29d29 100644 --- a/earwigbot/config/__init__.py +++ b/earwigbot/config/__init__.py @@ -43,6 +43,7 @@ except ImportError: from earwigbot.config.formatter import BotFormatter from earwigbot.config.node import ConfigNode +from earwigbot.config.permissions import PermissionsDB from earwigbot.exceptions import NoConfigError __all__ = ["BotConfig"] @@ -77,8 +78,10 @@ class BotConfig(object): def __init__(self, root_dir, level): self._root_dir = root_dir self._logging_level = level - self._config_path = path.join(self._root_dir, "config.yml") - self._log_dir = path.join(self._root_dir, "logs") + self._config_path = path.join(self.root_dir, "config.yml") + self._log_dir = path.join(self.root_dir, "logs") + perms_file = path.join(self.root_dir, "permissions.db") + self._permissions = PermissionsDB(perms_file) self._decryption_cipher = None self._data = None @@ -291,6 +294,10 @@ class BotConfig(object): for node, nodes in self._decryptable_nodes: self._decrypt(node, nodes) + if self.irc: + self.irc["permissions"] = self._permissions + self._permissions.load() + def decrypt(self, node, *nodes): """Decrypt an object in our config tree. diff --git a/earwigbot/config/node.py b/earwigbot/config/node.py index a5746e2..36f7a1e 100644 --- a/earwigbot/config/node.py +++ b/earwigbot/config/node.py @@ -26,9 +26,14 @@ class ConfigNode(object): def __init__(self): self._data = {} - def __iter__(self): - for key in self._data: - yield key + def __repr__(self): + return self._data + + def __nonzero__(self): + return bool(self._data) + + def __len__(self): + retrun len(self._data) def __getitem__(self, key): return self._data[key] @@ -42,6 +47,13 @@ class ConfigNode(object): def __setattr__(self, key, item): self._data[key] = item + def __iter__(self): + for key in self._data: + yield key + + def __contains__(self, item): + return item in self._data + def _dump(self): data = self._data.copy() for key, val in data.iteritems(): diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py new file mode 100644 index 0000000..d3f6a30 --- /dev/null +++ b/earwigbot/config/permissions.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sqlite3 as sqlite +from threading import Lock + +__all__ = ["PermissionsDB"] + +class PermissionsDB(object): + def __init__(self, dbfile): + self._dbfile = dbfile + self._db_access_lock = Lock() + self._data = {} + + def __repr__(self): + """Return the canonical string representation of the PermissionsDB.""" + res = "PermissionsDB(dbfile={0!r})" + return res.format(self._dbfile) + + def __str__(self): + """Return a nice string representation of the PermissionsDB.""" + return "".format(self._dbfile) + + def _create(self, conn): + """Initialize the permissions database with its necessary tables.""" + query = """CREATE TABLE users (user_nick, user_ident, user_host, + user_rank)""" + conn.execute(query) + + def load(self): + """Load permissions from an existing database, or create a new one.""" + query = "SELECT user_nick, user_ident, user_host, user_rank FROM users" + self._data = {} + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + for nick, ident, host, rank in conn.execute(query): + try: + self._data[rank].append(_User(nick, ident, host)) + except KeyError: + self._data[rank] = [_User(nick, ident, host)] + except sqlite.OperationalError: + self._create(conn) + + +class _User(object): + def __init__(self, nick, ident, host): + self.nick = nick + self.ident = ident + self.host = host From 992faba70e67d30b119bc6bce5ba6a889bfbe812 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 15:14:30 -0400 Subject: [PATCH 04/11] Implement is_admin(), is_owner(). --- earwigbot/config/permissions.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index d3f6a30..44a96a4 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -26,6 +26,9 @@ from threading import Lock __all__ = ["PermissionsDB"] class PermissionsDB(object): + ADMIN = 1 + OWNER = 2 + def __init__(self, dbfile): self._dbfile = dbfile self._db_access_lock = Lock() @@ -46,6 +49,15 @@ class PermissionsDB(object): user_rank)""" conn.execute(query) + def _is_rank(self, user, rank): + """Return True if the given user has the given rank, else False.""" + try: + for rule in self._data[rank]: + if user == rule: + return True + except KeyError: + return False + def load(self): """Load permissions from an existing database, or create a new one.""" query = "SELECT user_nick, user_ident, user_host, user_rank FROM users" @@ -60,9 +72,38 @@ class PermissionsDB(object): except sqlite.OperationalError: self._create(conn) + def is_admin(self, nick="*", ident="*", host="*"): + """Return ``True`` if the given user is a bot admin, else ``False``.""" + return self._is_rank(_User(nick, ident, host), rank=self.ADMIN) + + def is_owner(self, nick="*", ident="*", host="*"): + """Return ``True`` if the given user is a bot owner, else ``False``.""" + return self._is_rank(_User(nick, ident, host), rank=self.OWNER) class _User(object): + """A class that represents an IRC user for the purpose of testing rules.""" def __init__(self, nick, ident, host): self.nick = nick self.ident = ident self.host = host + + def __repr__(self): + """Return the canonical string representation of the User.""" + res = "_User(nick={0!r}, ident={1!r}, host={2!r})" + return res.format(self.nick, self.ident, self.host) + + def __str__(self): + """Return a nice string representation of the User.""" + return "{0}!{1}@{2}".format(self.nick, self.ident, self.host) + + def __eq__(self, user): + if self.nick == user.nick or (self.nick == "*" or user.nick == "*"): + if self.ident == user.ident or (self.ident == "*" or + user.ident == "*"): + if self.host == user.host or (self.host == "*" or + user.host == "*"): + return True + return False + + def __ne__(self, user): + return not self == user From 0892cf6bd29749a9d78ad62a2afd99bb424561b0 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 15:47:44 -0400 Subject: [PATCH 05/11] Implement add_admin(), add_owner(), better comparison for User. --- earwigbot/config/permissions.py | 43 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 44a96a4..963a8d3 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -58,6 +58,16 @@ class PermissionsDB(object): except KeyError: return False + def _set_rank(self, user, rank): + """Add a User to the database under a given rank.""" + try: + self._data[rank].append(user) + except KeyError: + self._data[rank] = [user] + query = "INSERT INTO users VALUES (?, ?, ?, ?)" + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + conn.execute(query, (user.nick, user.ident, user.host, rank)) + def load(self): """Load permissions from an existing database, or create a new one.""" query = "SELECT user_nick, user_ident, user_host, user_rank FROM users" @@ -80,6 +90,14 @@ class PermissionsDB(object): """Return ``True`` if the given user is a bot owner, else ``False``.""" return self._is_rank(_User(nick, ident, host), rank=self.OWNER) + def add_admin(self, nick="*", ident="*", host="*"): + """Add an nick/ident/host combo to the bot admins list.""" + return self._set_rank(_User(nick, ident, host), rank=self.ADMIN) + + def add_owner(self, nick="*", ident="*", host="*"): + """Add a nick/ident/host combo to the bot owners list.""" + return self._set_rank(_User(nick, ident, host), rank=self.OWNER) + class _User(object): """A class that represents an IRC user for the purpose of testing rules.""" def __init__(self, nick, ident, host): @@ -97,13 +115,28 @@ class _User(object): return "{0}!{1}@{2}".format(self.nick, self.ident, self.host) def __eq__(self, user): - if self.nick == user.nick or (self.nick == "*" or user.nick == "*"): - if self.ident == user.ident or (self.ident == "*" or - user.ident == "*"): - if self.host == user.host or (self.host == "*" or - user.host == "*"): + if self._compare(self.nick, user.nick): + if self._compare(self.ident, user.ident): + if self._compare(self.host, user.host): return True return False def __ne__(self, user): return not self == user + + def _compare(self, field1, field2): + if field1 == "*" or field2 == "*": + return True + if "*" in field1: + regex = re.escape(field1).replace(r"\*", r".*?") + "$" + if re.match(regex, field2, re.I): + if "*" in field2: + regex = re.escape(field2).replace(r"\*", r".*?") + "$" + return re.match(regex, field1, re.I) + return True + else: + return False + elif "*" in field2: + regex = re.escape(field2).replace(r"\*", r".*?") + "$" + return re.match(regex, field1, re.I) + return field1 == field2 From 3182df8e73e4f36e327fbc9789274014b1dcd26f Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 16:46:57 -0400 Subject: [PATCH 06/11] Better matching. --- earwigbot/config/permissions.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 963a8d3..4860cba 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -20,6 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from fnmatch import fnmatch import sqlite3 as sqlite from threading import Lock @@ -53,7 +54,7 @@ class PermissionsDB(object): """Return True if the given user has the given rank, else False.""" try: for rule in self._data[rank]: - if user == rule: + if user in rule: return True except KeyError: return False @@ -114,29 +115,9 @@ class _User(object): """Return a nice string representation of the User.""" return "{0}!{1}@{2}".format(self.nick, self.ident, self.host) - def __eq__(self, user): - if self._compare(self.nick, user.nick): - if self._compare(self.ident, user.ident): - if self._compare(self.host, user.host): + def __contains__(self, user): + if fnmatch(user.nick, self.nick): + if fnmatch(user.ident, self.ident): + if fnmatch(user.host, self.host): return True return False - - def __ne__(self, user): - return not self == user - - def _compare(self, field1, field2): - if field1 == "*" or field2 == "*": - return True - if "*" in field1: - regex = re.escape(field1).replace(r"\*", r".*?") + "$" - if re.match(regex, field2, re.I): - if "*" in field2: - regex = re.escape(field2).replace(r"\*", r".*?") + "$" - return re.match(regex, field1, re.I) - return True - else: - return False - elif "*" in field2: - regex = re.escape(field2).replace(r"\*", r".*?") + "$" - return re.match(regex, field1, re.I) - return field1 == field2 From bff57f55bcbf4b83096b36d6c6699315d00fb59f Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 16:54:33 -0400 Subject: [PATCH 07/11] Implement new permissions system in bot commands. --- earwigbot/commands/chanops.py | 2 +- earwigbot/commands/git_command.py | 2 +- earwigbot/commands/quit.py | 2 +- earwigbot/commands/threads.py | 2 +- earwigbot/config/permissions.py | 10 ++++++---- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index 83399fc..ea54500 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -36,7 +36,7 @@ class ChanOps(Command): de_escalate = data.command in ["devoice", "deop"] if de_escalate and (not data.args or data.args[0] == data.nick): target = data.nick - elif data.host not in self.config.irc["permissions"]["admins"]: + elif not self.config.irc["permissions"].is_admin(data): self.reply(data, "You must be a bot admin to use this command.") return diff --git a/earwigbot/commands/git_command.py b/earwigbot/commands/git_command.py index 645ed04..b57ee05 100644 --- a/earwigbot/commands/git_command.py +++ b/earwigbot/commands/git_command.py @@ -39,7 +39,7 @@ class Git(Command): def process(self, data): self.data = data - if data.host not in self.config.irc["permissions"]["owners"]: + if not self.config.irc["permissions"].is_owner(data): msg = "You must be a bot owner to use this command." self.reply(data, msg) return diff --git a/earwigbot/commands/quit.py b/earwigbot/commands/quit.py index 5d8a1b2..0331d08 100644 --- a/earwigbot/commands/quit.py +++ b/earwigbot/commands/quit.py @@ -29,7 +29,7 @@ class Quit(Command): commands = ["quit", "restart", "reload"] def process(self, data): - if data.host not in self.config.irc["permissions"]["owners"]: + if not self.config.irc["permissions"].is_owner(data): self.reply(data, "You must be a bot owner to use this command.") return if data.command == "quit": diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index 385ac1c..e878d09 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -32,7 +32,7 @@ class Threads(Command): def process(self, data): self.data = data - if data.host not in self.config.irc["permissions"]["owners"]: + if not self.config.irc["permissions"].is_owner(data): msg = "You must be a bot owner to use this command." self.reply(data, msg) return diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 4860cba..b05f215 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -83,13 +83,15 @@ class PermissionsDB(object): except sqlite.OperationalError: self._create(conn) - def is_admin(self, nick="*", ident="*", host="*"): + def is_admin(self, data): """Return ``True`` if the given user is a bot admin, else ``False``.""" - return self._is_rank(_User(nick, ident, host), rank=self.ADMIN) + user = _User(data.nick, data.ident, data.host) + return self._is_rank(user, rank=self.ADMIN) - def is_owner(self, nick="*", ident="*", host="*"): + def is_owner(self, data): """Return ``True`` if the given user is a bot owner, else ``False``.""" - return self._is_rank(_User(nick, ident, host), rank=self.OWNER) + user = _User(data.nick, data.ident, data.host) + return self._is_rank(user, rank=self.OWNER) def add_admin(self, nick="*", ident="*", host="*"): """Add an nick/ident/host combo to the bot admins list.""" From d4e91066670621040093ba0c37fbdb38dcd69b3d Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 17:44:25 -0400 Subject: [PATCH 08/11] Implement remove_admin()/owner(); fixing some other things. --- earwigbot/config/permissions.py | 32 +++++++++++++++++++++++++++++++- earwigbot/irc/connection.py | 9 +++++++-- earwigbot/wiki/copyvios/exclusions.py | 2 +- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index b05f215..6375cf3 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -27,6 +27,12 @@ from threading import Lock __all__ = ["PermissionsDB"] class PermissionsDB(object): + """ + **EarwigBot: Permissions Database Manager** + + Controls the :file:`permissions.db` file, which stores the bot's owners and + admins for the purposes of using certain dangerous IRC commands. + """ ADMIN = 1 OWNER = 2 @@ -68,6 +74,22 @@ class PermissionsDB(object): query = "INSERT INTO users VALUES (?, ?, ?, ?)" with sqlite.connect(self._dbfile) as conn, self._db_access_lock: conn.execute(query, (user.nick, user.ident, user.host, rank)) + return user + + def _del_rank(self, user, rank): + """Remove a User from the database.""" + query = """DELETE FROM users WHERE user_nick = ? AND user_ident = ? AND + user_host = ? AND user_rank = ?""" + try: + for rule in self._data[rank]: + if user in rule: + with self._db_access_lock: + with sqlite.connect(self._dbfile) as conn: + args = (user.nick, user.ident, user.host, rank) + conn.execute(query, args) + return rule + except KeyError: + return None def load(self): """Load permissions from an existing database, or create a new one.""" @@ -94,13 +116,21 @@ class PermissionsDB(object): return self._is_rank(user, rank=self.OWNER) def add_admin(self, nick="*", ident="*", host="*"): - """Add an nick/ident/host combo to the bot admins list.""" + """Add a nick/ident/host combo to the bot admins list.""" return self._set_rank(_User(nick, ident, host), rank=self.ADMIN) def add_owner(self, nick="*", ident="*", host="*"): """Add a nick/ident/host combo to the bot owners list.""" return self._set_rank(_User(nick, ident, host), rank=self.OWNER) + def remove_admin(self, nick="*", ident="*", host="*"): + """Remove a nick/ident/host combo to the bot admins list.""" + return self._del_rank(_User(nick, ident, host), rank=self.ADMIN) + + def remove_owner(self, nick="*", ident="*", host="*"): + """Remove a nick/ident/host combo to the bot owners list.""" + return self._del_rank(_User(nick, ident, host), rank=self.OWNER) + class _User(object): """A class that represents an IRC user for the purpose of testing rules.""" def __init__(self, nick, ident, host): diff --git a/earwigbot/irc/connection.py b/earwigbot/irc/connection.py index da38e3f..c6cb1c6 100644 --- a/earwigbot/irc/connection.py +++ b/earwigbot/irc/connection.py @@ -43,6 +43,7 @@ class IRCConnection(object): self._send_lock = Lock() self._last_recv = time() + self._last_send = 0 self._last_ping = 0 def __repr__(self): @@ -87,6 +88,9 @@ class IRCConnection(object): def _send(self, msg, hidelog=False): """Send data to the server.""" with self._send_lock: + time_since_last = time() - self._last_send + if time_since_last < 0.75: + time.sleep(0.75 - time_since_last) try: self._sock.sendall(msg + "\r\n") except socket.error: @@ -94,6 +98,7 @@ class IRCConnection(object): else: if not hidelog: self.logger.debug(msg) + self._last_send = time() def _split(self, msgs, maxlen, maxsplits=3): """Split a large message into multiple messages smaller than maxlen.""" @@ -158,7 +163,7 @@ class IRCConnection(object): def say(self, target, msg, hidelog=False): """Send a private message to a target on the server.""" - for msg in self._split(msg, 500 - len(target)): + for msg in self._split(msg, 400): msg = "PRIVMSG {0} :{1}".format(target, msg) self._send(msg, hidelog) @@ -177,7 +182,7 @@ class IRCConnection(object): def notice(self, target, msg, hidelog=False): """Send a notice to a target on the server.""" - for msg in self._split(msg, 500 - len(target)): + for msg in self._split(msg, 400): msg = "NOTICE {0} :{1}".format(target, msg) self._send(msg, hidelog) diff --git a/earwigbot/wiki/copyvios/exclusions.py b/earwigbot/wiki/copyvios/exclusions.py index 23c1daf..3600f97 100644 --- a/earwigbot/wiki/copyvios/exclusions.py +++ b/earwigbot/wiki/copyvios/exclusions.py @@ -44,7 +44,7 @@ class ExclusionsDB(object): """ **EarwigBot: Wiki Toolset: Exclusions Database Manager** - Controls the :file:`.exclusions.db` file, which stores URLs excluded from + Controls the :file:`exclusions.db` file, which stores URLs excluded from copyright violation checks on account of being known mirrors, for example. """ From ef7d4f441bbddaeee484117a0137abdd21253119 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 18:28:19 -0400 Subject: [PATCH 09/11] Starting work on !access command. --- earwigbot/commands/access.py | 66 +++++++++++++++++++++++++++++++++++++++ earwigbot/commands/git_command.py | 2 +- earwigbot/config/permissions.py | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 earwigbot/commands/access.py diff --git a/earwigbot/commands/access.py b/earwigbot/commands/access.py new file mode 100644 index 0000000..8946f25 --- /dev/null +++ b/earwigbot/commands/access.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import random + +from earwigbot.commands import Command + +class Access(Command): + """Control and get info on who can access the bot.""" + name = "access" + commands = ["access", "permission", "permissions", "perm", "perms"] + + def process(self, data): + if not data.args: + self.reply(data, "Subcommands are self, list, add, remove.") + return + db = self.config.irc["permissions"] + if data.args[0] == "self": + self.do_self(data) + elif data.args[0] == "list": + self.do_list(data) + elif data.args[0] == "add": + self.do_add(data) + elif data.args[0] == "remove": + self.do_remove(data) + else: + msg = "Unknown subcommand \x0303{0}\x0F.".format(data.args[0]) + self.reply(data, msg) + + def do_self(self, data): + if db.is_owner(data): + msg = "You are a bot owner (matching rule \x0302{0}\x0F)." + self.reply(data, msg.format(db.is_owner(data))) + elif db.is_admin(data): + msg = "You are a bot admin (matching rule \x0302{0}\x0F)." + self.reply(data, msg.format(db.is_admin(data))) + else: + self.reply(data, "You do not match any bot access rules.") + + def do_list(self, data): + pass + + def do_add(self, data): + pass + + def do_remove(self, data): + pass diff --git a/earwigbot/commands/git_command.py b/earwigbot/commands/git_command.py index b57ee05..cc6cbe5 100644 --- a/earwigbot/commands/git_command.py +++ b/earwigbot/commands/git_command.py @@ -78,7 +78,7 @@ class Git(Command): elif command == "status": self.do_status() else: # They asked us to do something we don't know - msg = "Ynknown argument: \x0303{0}\x0F.".format(data.args[0]) + msg = "Unknown argument: \x0303{0}\x0F.".format(data.args[0]) self.reply(data, msg) def get_repos(self): diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 6375cf3..00c375d 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -61,7 +61,7 @@ class PermissionsDB(object): try: for rule in self._data[rank]: if user in rule: - return True + return rule except KeyError: return False From 5f9199bed0e3b924c6fd47472ab9c3097026c9c5 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 19:54:13 -0400 Subject: [PATCH 10/11] Implement a few more subcommands for !access. --- earwigbot/commands/access.py | 89 +++++++++++++++++++++++++++++++++++------ earwigbot/config/permissions.py | 23 ++++++++++- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/earwigbot/commands/access.py b/earwigbot/commands/access.py index 8946f25..d908b43 100644 --- a/earwigbot/commands/access.py +++ b/earwigbot/commands/access.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import random +import re from earwigbot.commands import Command @@ -35,18 +35,18 @@ class Access(Command): return db = self.config.irc["permissions"] if data.args[0] == "self": - self.do_self(data) + self.do_self(data, db) elif data.args[0] == "list": - self.do_list(data) + self.do_list(data, db) elif data.args[0] == "add": - self.do_add(data) + self.do_add(data, db) elif data.args[0] == "remove": - self.do_remove(data) + self.do_remove(data, db) else: msg = "Unknown subcommand \x0303{0}\x0F.".format(data.args[0]) self.reply(data, msg) - def do_self(self, data): + def do_self(self, data, db): if db.is_owner(data): msg = "You are a bot owner (matching rule \x0302{0}\x0F)." self.reply(data, msg.format(db.is_owner(data))) @@ -56,11 +56,76 @@ class Access(Command): else: self.reply(data, "You do not match any bot access rules.") - def do_list(self, data): - pass + def do_list(self, data, db): + if len(data.args) > 1: + if data.args[1] in ["owner", "owners"]: + name, rules = "owners", db.data.get(db.OWNERS) + elif data.args[1] in ["admin", "admins"]: + name, rules = "admins", db.data.get(db.ADMINS) + else: + msg = "Unknown access level \x0302{0}\x0F." + self.reply(data, msg.format(data.args[1])) + return + if rules: + msg = "Bot {0}: {1}.".format(name, ", ".join(map(str, rules))) + else: + msg = "No bot {0}.".format(name) + self.reply(data, msg) + + else: + owners = len(db.data.get(db.OWNERS, [])) + admins = len(db.data.get(db.ADMINS, [])) + msg = "There are {0} bot owners and {1} bot admins. Use '!{2} list owners' or '!{2} list admins' for details." + self.reply(data, msg.format(owners, admins, data.command)) + + def do_add(self, data, db): + if not db.is_owner(data): + msg = "You must be a bot owner to add users to the access list." + self.reply(data, msg) + return + + levels = ["owner", "owners", "admin", "admins"] + if len(data.args) == 1 or data.args[1] not in levels: + msg = "Please specify an access level ('owners' or 'admins')." + self.reply(data, msg) + return + if len(data.args) == 2: + self.no_arg_error(data) + return + + if "nick" in data.kwargs or "ident" in kwargs or "host" in kwargs: + nick = data.kwargs.get("nick", "*") + ident = data.kwargs.get("ident", "*") + host = data.kwargs.get("host", "*") + else: + user = re.match(r"(.*?)!(.*?)@(.*?)$", data.args[2]) + if not user: + self.no_arg_error(data) + return + nick, ident, host = user.group(1), user.group(2), user.group(3) - def do_add(self, data): - pass + if data.args[1] in ["owner", "owners"]: + if db.has_exact(nick, ident, host, db.OWNER): + msg = "\x0302{0}\x0F is already a bot owner.".format(rule) + self.reply(data, msg) + else: + rule = db.add_owner(nick, ident, host) + self.reply(data, "Added bot owner \x0302{0}\x0F.".format(rule)) + else: + if db.has_exact(nick, ident, host, db.OWNER): + msg = "\x0302{0}\x0F is already a bot admin.".format(rule) + self.reply(data, msg) + else: + rule = db.add_admin(nick, ident, host) + self.reply(data, "Added bot admin \x0302{0}\x0F.".format(rule)) + + def do_remove(self, data, db): + if not db.is_owner(data): + msg = "You must be a bot owner to remove users from the access list." + self.reply(data, msg) + return - def do_remove(self, data): - pass + def no_arg_error(self, data): + msg = 'Please specify a user, either as "\x0302nick\x0F!\x0302ident\x0F@\x0302host\x0F"' + msg += ' or "nick=\x0302nick\x0F, ident=\x0302ident\x0F, host=\x0302host\x0F".' + self.reply(data, msg) diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 00c375d..0df1b2f 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -63,7 +63,8 @@ class PermissionsDB(object): if user in rule: return rule except KeyError: - return False + pass + return False def _set_rank(self, user, rank): """Add a User to the database under a given rank.""" @@ -89,7 +90,13 @@ class PermissionsDB(object): conn.execute(query, args) return rule except KeyError: - return None + pass + return None + + @property + def data(self): + """A dict of all entries in the permissions database.""" + return self._data def load(self): """Load permissions from an existing database, or create a new one.""" @@ -105,6 +112,17 @@ class PermissionsDB(object): except sqlite.OperationalError: self._create(conn) + def has_exact(self, nick="*", ident="*", host="*", rule): + """Return ``True`` if there is an exact match for this rule.""" + try: + for usr in self._data[rank]: + if nick != usr.nick or ident != usr.ident or host != usr.host: + continue + return usr + except KeyError: + pass + return False + def is_admin(self, data): """Return ``True`` if the given user is a bot admin, else ``False``.""" user = _User(data.nick, data.ident, data.host) @@ -131,6 +149,7 @@ class PermissionsDB(object): """Remove a nick/ident/host combo to the bot owners list.""" return self._del_rank(_User(nick, ident, host), rank=self.OWNER) + class _User(object): """A class that represents an IRC user for the purpose of testing rules.""" def __init__(self, nick, ident, host): From 16e592ab1a96b6d853cd792fd4f1bdaf40e574bb Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Thu, 9 Aug 2012 20:07:19 -0400 Subject: [PATCH 11/11] Implement !access remove, plus cleanup/refactor/condense. --- earwigbot/commands/access.py | 68 +++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/earwigbot/commands/access.py b/earwigbot/commands/access.py index d908b43..0118674 100644 --- a/earwigbot/commands/access.py +++ b/earwigbot/commands/access.py @@ -71,7 +71,6 @@ class Access(Command): else: msg = "No bot {0}.".format(name) self.reply(data, msg) - else: owners = len(db.data.get(db.OWNERS, [])) admins = len(db.data.get(db.ADMINS, [])) @@ -79,11 +78,44 @@ class Access(Command): self.reply(data, msg.format(owners, admins, data.command)) def do_add(self, data, db): + user = self.get_user_from_args(data) + if user: + nick, ident, host = user + if data.args[1] in ["owner", "owners"]: + name, level, adder = "owner", db.OWNER, db.add_owner + else: + name, level, adder = "admin", db.ADMIN, db.add_admin + if db.has_exact(nick, ident, host, level): + rule = "{0}!{1}@{2}".format(nick, ident, host) + msg = "\x0302{0}\x0F is already a bot {1}.".format(rule, name) + self.reply(data, msg) + else: + rule = adder(nick, ident, host) + msg = "Added bot {0} \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + + def do_remove(self, data, db): + user = self.get_user_from_args(data) + if user: + nick, ident, host = user + if data.args[1] in ["owner", "owners"]: + name, level, rmver = "owner", db.OWNER, db.remove_owner + else: + name, level, rmver = "admin", db.ADMIN, db.remove_admin + rule = rmver(nick, ident, host) + if rule: + msg = "Removed bot {0} \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + else: + rule = "{0}!{1}@{2}".format(nick, ident, host) + msg = "No bot {0} matching \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + + def get_user_from_args(self, data): if not db.is_owner(data): msg = "You must be a bot owner to add users to the access list." self.reply(data, msg) return - levels = ["owner", "owners", "admin", "admins"] if len(data.args) == 1 or data.args[1] not in levels: msg = "Please specify an access level ('owners' or 'admins')." @@ -92,38 +124,16 @@ class Access(Command): if len(data.args) == 2: self.no_arg_error(data) return - if "nick" in data.kwargs or "ident" in kwargs or "host" in kwargs: nick = data.kwargs.get("nick", "*") ident = data.kwargs.get("ident", "*") host = data.kwargs.get("host", "*") - else: - user = re.match(r"(.*?)!(.*?)@(.*?)$", data.args[2]) - if not user: - self.no_arg_error(data) - return - nick, ident, host = user.group(1), user.group(2), user.group(3) - - if data.args[1] in ["owner", "owners"]: - if db.has_exact(nick, ident, host, db.OWNER): - msg = "\x0302{0}\x0F is already a bot owner.".format(rule) - self.reply(data, msg) - else: - rule = db.add_owner(nick, ident, host) - self.reply(data, "Added bot owner \x0302{0}\x0F.".format(rule)) - else: - if db.has_exact(nick, ident, host, db.OWNER): - msg = "\x0302{0}\x0F is already a bot admin.".format(rule) - self.reply(data, msg) - else: - rule = db.add_admin(nick, ident, host) - self.reply(data, "Added bot admin \x0302{0}\x0F.".format(rule)) - - def do_remove(self, data, db): - if not db.is_owner(data): - msg = "You must be a bot owner to remove users from the access list." - self.reply(data, msg) + return nick, ident, host + user = re.match(r"(.*?)!(.*?)@(.*?)$", data.args[2]) + if not user: + self.no_arg_error(data) return + return user.group(1), user.group(2), user.group(3) def no_arg_error(self, data): msg = 'Please specify a user, either as "\x0302nick\x0F!\x0302ident\x0F@\x0302host\x0F"'