diff --git a/poetry.lock b/poetry.lock index 4fa9e774..c7f408d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -66,6 +66,21 @@ version = "2.8.0" [package.dependencies] pytz = ">=2015.7" +[[package]] +category = "main" +description = "Modern password hashing for your software and your servers" +name = "bcrypt" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.1.7" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,<3.3.0 || >3.3.0)"] + [[package]] category = "main" description = "Python package for providing Mozilla's CA Bundle." @@ -913,8 +928,8 @@ python-versions = "*" version = "2020.1.24" [extras] -alchemy_easy = ["sqlalchemy", "psycopg2_binary"] -alchemy_hard = ["sqlalchemy", "psycopg2"] +alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] +alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] bard = ["ffmpeg_python", "youtube_dl", "eyed3"] coloredlogs = ["coloredlogs"] constellation = ["starlette", "uvicorn", "python-multipart"] @@ -925,7 +940,7 @@ sentry = ["sentry_sdk"] telegram = ["python_telegram_bot"] [metadata] -content-hash = "f275cd948fe28423a90d37d2825eabfec97e8ac0cdf52ee2d20f803d61987b40" +content-hash = "218f4a253a7ef17bb871abf541ae7182f1626cd43d55417de12f9561c58ca0e9" python-versions = "^3.8" [metadata.files] @@ -963,6 +978,26 @@ babel = [ {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, ] +bcrypt = [ + {file = "bcrypt-3.1.7-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7"}, + {file = "bcrypt-3.1.7-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win32.whl", hash = "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win_amd64.whl", hash = "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e"}, + {file = "bcrypt-3.1.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0"}, + {file = "bcrypt-3.1.7-cp34-abi3-macosx_10_6_intel.whl", hash = "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052"}, + {file = "bcrypt-3.1.7-cp34-abi3-manylinux1_x86_64.whl", hash = "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win32.whl", hash = "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win_amd64.whl", hash = "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win32.whl", hash = "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win_amd64.whl", hash = "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win32.whl", hash = "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win_amd64.whl", hash = "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win32.whl", hash = "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win_amd64.whl", hash = "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc"}, + {file = "bcrypt-3.1.7-cp38-cp38-win32.whl", hash = "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1"}, + {file = "bcrypt-3.1.7-cp38-cp38-win_amd64.whl", hash = "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752"}, + {file = "bcrypt-3.1.7.tar.gz", hash = "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42"}, +] certifi = [ {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, diff --git a/pycharm_templates/table.vm b/pycharm_templates/table.vm index c6134933..90eb3d5a 100644 --- a/pycharm_templates/table.vm +++ b/pycharm_templates/table.vm @@ -3,6 +3,7 @@ from sqlalchemy.orm import * from sqlalchemy.ext.declarative import declared_attr +# noinspection PyAttributeOutsideInit #set($CAPITALIZED_NAME = $NAME.substring(0,1).toUpperCase() + $NAME.substring(1)) class ${CAPITALIZED_NAME}: __tablename__ = "${NAME}" diff --git a/pyproject.toml b/pyproject.toml index a47f0fff..706b1cdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ sqlalchemy = {version="^1.3.10", optional=true} psycopg2 = {version="^2.8.4", optional=true} # Requires quite a bit of stuff http://initd.org/psycopg/docs/install.html#install-from-source psycopg2_binary = {version="^2.8.4", optional=true} # Prebuilt alternative to psycopg2, not recommended + bcrypt = {version="^3.1.7", optional=true} # constellation starlette = {version="^0.12.13", optional=true} @@ -71,8 +72,8 @@ telegram = ["python_telegram_bot"] discord = ["discord.py", "pynacl"] matrix = ["matrix-nio"] - alchemy_easy = ["sqlalchemy", "psycopg2_binary"] - alchemy_hard = ["sqlalchemy", "psycopg2"] + alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] + alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] bard = ["ffmpeg_python", "youtube_dl", "eyed3"] constellation = ["starlette", "uvicorn", "python-multipart"] sentry = ["sentry_sdk"] diff --git a/royalnet/backpack/commands/__init__.py b/royalnet/backpack/commands/__init__.py index 3b99caa5..50c1aa68 100644 --- a/royalnet/backpack/commands/__init__.py +++ b/royalnet/backpack/commands/__init__.py @@ -1,22 +1,10 @@ # Imports go here! from .version import VersionCommand -from .exception import ExceptionCommand -from .excevent import ExceventCommand -from .keyboardtest import KeyboardtestCommand # Enter the commands of your Pack here! available_commands = [ VersionCommand, ] -# noinspection PyUnreachableCode -if __debug__: - available_commands = [ - *available_commands, - ExceptionCommand, - ExceventCommand, - KeyboardtestCommand, - ] - # Don't change this, it should automatically generate __all__ __all__ = [command.__name__ for command in available_commands] diff --git a/royalnet/backpack/commands/exception.py b/royalnet/backpack/commands/exception.py deleted file mode 100644 index 5419b0b3..00000000 --- a/royalnet/backpack/commands/exception.py +++ /dev/null @@ -1,11 +0,0 @@ -import royalnet -from royalnet.commands import * - - -class ExceptionCommand(Command): - name: str = "exception" - - description: str = "Raise an exception in the command." - - async def run(self, args: CommandArgs, data: CommandData) -> None: - raise Exception(f"{self.interface.prefix}{self.name} was called") diff --git a/royalnet/backpack/commands/excevent.py b/royalnet/backpack/commands/excevent.py deleted file mode 100644 index 38220942..00000000 --- a/royalnet/backpack/commands/excevent.py +++ /dev/null @@ -1,12 +0,0 @@ -import royalnet -from royalnet.commands import * - - -class ExceventCommand(Command): - name: str = "excevent" - - description: str = "Call an event that raises an exception." - - async def run(self, args: CommandArgs, data: CommandData) -> None: - await self.interface.call_herald_event(self.interface.name, "exception") - await data.reply("✅ Event called!") diff --git a/royalnet/backpack/commands/keyboardtest.py b/royalnet/backpack/commands/keyboardtest.py deleted file mode 100644 index 4f24fade..00000000 --- a/royalnet/backpack/commands/keyboardtest.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import * -from royalnet.commands import * -import functools -import asyncio - - -class KeyboardtestCommand(Command): - name: str = "keyboardtest" - - description: str = "Create a new keyboard with the specified keys." - - syntax: str = "{keys}+" - - @staticmethod - async def echo(data: CommandData, echo: str): - await data.reply(echo) - - async def run(self, args: CommandArgs, data: CommandData) -> None: - keys = [] - for arg in args: - # noinspection PyTypeChecker - keys.append(KeyboardKey(interface=self.interface, - short=arg[0], - text=arg, - callback=functools.partial(self.echo, echo=arg))) - async with data.keyboard("This is a test keyboard.", keys): - await asyncio.sleep(10) - await data.reply("The keyboard is no longer in scope.") diff --git a/royalnet/backpack/commands/link.py b/royalnet/backpack/commands/link.py new file mode 100644 index 00000000..30c38730 --- /dev/null +++ b/royalnet/backpack/commands/link.py @@ -0,0 +1,86 @@ +from typing import * +import royalnet +import royalnet.commands as rc +import royalnet.utils as ru +from ..tables.telegram import Telegram +from ..tables.discord import Discord + + +class SyncCommand(rc.Command): + name: str = "sync" + + description: str = "Connect your chat account to Royalnet!" + + syntax: str = "{username} {password}" + + async def run(self, args: rc.CommandArgs, data: rc.CommandData) -> None: + username = args[0] + password = " ".join(args[1:]) + + author = await data.get_author(error_if_none=True) + + user = await data.find_user(username) + try: + successful = user.test_password(password) + except ValueError: + raise rc.UserError(f"User {user} has no password set!") + if not successful: + raise rc.InvalidInputError(f"Invalid password!") + + if self.interface.name == "telegram": + import telegram + message: telegram.Message = data.message + from_user: telegram.User = message.from_user + TelegramT = self.alchemy.get(Telegram) + tg_user: Telegram = await ru.asyncify( + data.session.query(TelegramT).filter_by(tg_id=from_user.id).one_or_none + ) + if tg_user is None: + # Create + tg_user = TelegramT( + user=author, + tg_id=from_user.id, + first_name=from_user.first_name, + last_name=from_user.last_name, + username=from_user.username + ) + data.session.add(tg_user) + else: + # Edit + tg_user.first_name = from_user.first_name + tg_user.last_name = from_user.last_name + tg_user.username = from_user.username + await data.session_commit() + await data.reply(f"↔️ Account {tg_user} synced to {author}!") + + elif self.interface.name == "discord": + import discord + message: discord.Message = data.message + author: discord.User = message.author + DiscordT = self.alchemy.get(Discord) + ds_user: Discord = await ru.asyncify( + data.session.query(DiscordT).filter_by(discord_id=author.id).one_or_none + ) + if ds_user is None: + # Create + ds_user = DiscordT( + user=author, + discord_id=author.id, + username=author.name, + discriminator=author.discriminator, + avatar_url=author.avatar_url + ) + data.session.add(ds_user) + else: + # Edit + ds_user.username = author.name + ds_user.discriminator = author.discriminator + ds_user.avatar_url = author.avatar_url + await data.session_commit() + await data.reply(f"↔️ Account {ds_user} synced to {author}!") + + elif self.interface.name == "matrix": + raise rc.UnsupportedError(f"{self} hasn't been implemented for Matrix yet") + + else: + raise rc.UnsupportedError(f"Unknown interface: {self.interface.name}") diff --git a/royalnet/backpack/tables/aliases.py b/royalnet/backpack/tables/aliases.py index 0f4ec1f8..544c78f3 100644 --- a/royalnet/backpack/tables/aliases.py +++ b/royalnet/backpack/tables/aliases.py @@ -10,26 +10,30 @@ class Alias: __tablename__ = "aliases" @declared_attr - def royal_id(self): - return Column(Integer, ForeignKey("users.uid")) + def user_id(self): + return Column(Integer, ForeignKey("users.uid"), primary_key=True) @declared_attr def alias(self): return Column(String, primary_key=True) @declared_attr - def royal(self): + def user(self): return relationship("User", backref="aliases") @classmethod - def find_by_alias(cls, alchemy, session, alias: str): + def find_user(cls, alchemy, session, alias: str): result = session.query(alchemy.get(cls)).filter_by(alias=alias.lower()).one_or_none() if result is not None: result = result.royal return result + def __init__(self, user: str, alias: str): + self.user = user + self.alias = alias.lower() + def __repr__(self): return f"" def __str__(self): - return f"{self.alias}->{self.royal_id}" + return f"{self.alias}->{self.user_id}" diff --git a/royalnet/backpack/tables/discord.py b/royalnet/backpack/tables/discord.py index 2de86e7a..4f92dbdf 100644 --- a/royalnet/backpack/tables/discord.py +++ b/royalnet/backpack/tables/discord.py @@ -13,9 +13,13 @@ class Discord: __tablename__ = "discord" @declared_attr - def royal_id(self): + def user_id(self): return Column(Integer, ForeignKey("users.uid")) + @declared_attr + def user(self): + return relationship("User", backref="discord") + @declared_attr def discord_id(self): return Column(BigInteger, primary_key=True) @@ -29,13 +33,9 @@ class Discord: return Column(String) @declared_attr - def avatar_hash(self): + def avatar_url(self): return Column(String) - @declared_attr - def royal(self): - return relationship("User", backref="discord") - def __repr__(self): return f"" diff --git a/royalnet/backpack/tables/telegram.py b/royalnet/backpack/tables/telegram.py index d3798f6e..54de8090 100644 --- a/royalnet/backpack/tables/telegram.py +++ b/royalnet/backpack/tables/telegram.py @@ -13,9 +13,13 @@ class Telegram: __tablename__ = "telegram" @declared_attr - def royal_id(self): + def user_id(self): return Column(Integer, ForeignKey("users.uid")) + @declared_attr + def user(self): + return relationship("User", backref="telegram") + @declared_attr def tg_id(self): return Column(BigInteger, primary_key=True) @@ -32,10 +36,6 @@ class Telegram: def username(self): return Column(String) - @declared_attr - def royal(self): - return relationship("User", backref="telegram") - def __repr__(self): return f"" diff --git a/royalnet/backpack/tables/users.py b/royalnet/backpack/tables/users.py index 88ca5f87..d63a33a5 100644 --- a/royalnet/backpack/tables/users.py +++ b/royalnet/backpack/tables/users.py @@ -1,3 +1,4 @@ +import bcrypt from sqlalchemy import Column, \ Integer, \ String, \ @@ -5,6 +6,7 @@ from sqlalchemy import Column, \ from sqlalchemy.ext.declarative import declared_attr +# noinspection PyAttributeOutsideInit class User: __tablename__ = "users" @@ -36,6 +38,16 @@ class User: "avatar": self.avatar } + def set_password(self, password: str): + byte_password: bytes = bytes(password, encoding="UTF8") + self.password = bcrypt.hashpw(byte_password, bcrypt.gensalt(14)) + + def test_password(self, password: str): + if self.password is None: + raise ValueError("No password is set") + byte_password: bytes = bytes(password, encoding="UTF8") + return bcrypt.checkpw(byte_password, self.password) + def __repr__(self): return f"<{self.__class__.__qualname__} {self.username}>" diff --git a/royalnet/commands/commanddata.py b/royalnet/commands/commanddata.py index 93ee1213..4b966ca2 100644 --- a/royalnet/commands/commanddata.py +++ b/royalnet/commands/commanddata.py @@ -5,9 +5,11 @@ import asyncio as aio import royalnet.utils as ru from .errors import UnsupportedError from .commandinterface import CommandInterface +from royalnet.backpack.tables.aliases import Alias if TYPE_CHECKING: from .keyboardkey import KeyboardKey + from royalnet.backpack.tables.users import User log = logging.getLogger(__name__) @@ -64,6 +66,16 @@ class CommandData: if error_if_unavailable: raise UnsupportedError(f"'{self.delete_invoking.__name__}' is not supported") + async def find_user(self, alias: str) -> Optional["User"]: + """Find the User having a specific Alias. + + Parameters: + alias: the Alias to search for.""" + return await ru.asyncify( + Alias.find_user(self._interface.alchemy, self.session, alias) + ) + + @contextlib.asynccontextmanager async def keyboard(self, text, keys: List["KeyboardKey"]): yield diff --git a/royalnet/version.py b/royalnet/version.py index 03217183..63bf6999 100644 --- a/royalnet/version.py +++ b/royalnet/version.py @@ -1 +1 @@ -semantic = "5.4" +semantic = "5.5"