diff --git a/.gitignore b/.gitignore index 104e4de..062ffb8 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ venv.bak/ config.py images/* + +.DS_Store diff --git a/README.md b/README.md index 2c78b2f..e8a3b1b 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,107 @@ -# litecord +![Litecord logo](static/logo/logo.png) -Litecord is an open source implementation of Discord's backend and API in -Python. +Litecord is an open source, [clean-room design][clean-room] reimplementation of +Discord's HTTP API and Gateway in Python 3. This project is a rewrite of [litecord-reference]. +[clean-room]: https://en.wikipedia.org/wiki/Clean_room_design [litecord-reference]: https://gitlab.com/luna/litecord-reference -## Notes +## Project Goals - - Unit testing isn't completly perfect. - - No voice is planned to be developed, for now. - - You must figure out connecting to the server yourself. Litecord will not distribute - Discord's official client code nor provide ways to modify the client. +- Being able to unit test bots in an autonomous fashion. +- Doing research and exploration on the Discord API. -## Install +### Non-goals + +- Being used as a "self-hostable Discord alternative". + +## Caveats + +- Unit testing is incomplete. +- Currently, there are no plans to support voice chat. +- You must figure out how to connect to a Litecord instance. Litecord will not + distribute official client code from Discord nor provide ways to modify the + official client. + +## Installation Requirements: -- Python 3.7 or higher -- PostgreSQL -- gifsicle for GIF emoji and avatar handling. -- [Pipenv] + +- **Python 3.7+** +- PostgreSQL (tested using 9.6+) +- gifsicle for GIF emoji and avatar handling +- [pipenv] [pipenv]: https://github.com/pypa/pipenv +### Download the code + ```sh -$ git clone https://gitlab.com/luna/litecord.git && cd litecord +$ git clone https://gitlab.com/litecord/litecord.git && cd litecord +``` -# Setup the database: -# don't forget that you can create a specific -# postgres user just for the litecord database +### Setting up the database + +It's recommended to create a separate user for the `litecord` database. + +```sh +# Create the PostgreSQL database. $ createdb litecord + +# Apply the base schema to the database. $ psql -f schema.sql litecord +``` -# Configure litecord: -# edit config.py as you wish -$ cp config.example.py config.py +Then, you should run database migrations: -# run database migrations (this is a -# required step in setup) +```sh $ pipenv run ./manage.py migrate +``` -# Install all packages: +### Configuring + +Copy the `config.example.py` file and edit it to configure your instance: + +```sh +$ cp config.example.py config.py +$ $EDITOR config.py +``` + +### Install packages + +```sh $ pipenv install --dev ``` ## Running -Hypercorn is used to run litecord. By default, it will bind to `0.0.0.0:5000`. -You can use the `-b` option to change it (e.g. `-b 0.0.0.0:45000`). - -Use `--access-log -` to output access logs to stdout. +Hypercorn is used to run Litecord. By default, it will bind to `0.0.0.0:5000`. +This will expose your Litecord instance to the world. You can use the `-b` +option to change it (e.g. `-b 0.0.0.0:45000`). ```sh $ pipenv run hypercorn run:app ``` -*It is recommended to run litecord behind NGINX.* Because of that, -there is a basic `nginx.conf` file at the root. +You can use `--access-log -` to output access logs to stdout. -### Checking if it is working +**It is recommended to run litecord behind [NGINX].** You can use the +`nginx.conf` file at the root of the repository as a template. -You can check if your instance is running by checking the `/api/v6/gateway` -path. For basic websocket testing a tool such as +[nginx]: https://www.nginx.com + +### Does it work? + +You can check if your instance is running by performing an HTTP `GET` request on +the `/api/v6/gateway` endpoint. For basic websocket testing, a tool such as [ws](https://github.com/hashrocket/ws) can be used. ## Updating +Update the code and run any new database migrations: + ```sh $ git pull $ pipenv run ./manage.py migrate @@ -74,16 +109,16 @@ $ pipenv run ./manage.py migrate ## Running tests -To run tests we must create users that we know the passwords of. -Because of that, **never setup a testing environment in production.** +Running tests involves creating dummy users with known passwords. Because of +this, you should never setup a testing environment in production. ```sh -# setup the testing users +# Setup any testing users: $ pipenv run ./manage.py setup_tests -# make sure you have tox installed +# Install tox: $ pip install tox -# run basic linter and tests +# Run lints and tests: $ tox ``` diff --git a/litecord/auth.py b/litecord/auth.py index d9cd1e0..f631345 100644 --- a/litecord/auth.py +++ b/litecord/auth.py @@ -4,7 +4,7 @@ from random import randint import bcrypt from asyncpg import UniqueViolationError -from itsdangerous import Signer, BadSignature +from itsdangerous import TimestampSigner, BadSignature from logbook import Logger from quart import request, current_app as app @@ -38,7 +38,7 @@ async def raw_token_check(token, db=None): if not pwd_hash: raise Unauthorized('User ID not found') - signer = Signer(pwd_hash) + signer = TimestampSigner(pwd_hash) try: signer.unsign(token) diff --git a/litecord/blueprints/auth.py b/litecord/blueprints/auth.py index 21f38ff..8691c46 100644 --- a/litecord/blueprints/auth.py +++ b/litecord/blueprints/auth.py @@ -25,7 +25,7 @@ async def check_password(pwd_hash: str, given_password: str) -> bool: def make_token(user_id, user_pwd_hash) -> str: """Generate a single token for a user.""" - signer = itsdangerous.Signer(user_pwd_hash) + signer = itsdangerous.TimestampSigner(user_pwd_hash) user_id = base64.b64encode(str(user_id).encode()) return signer.sign(user_id).decode() diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index 79d8507..f8727b5 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -1,22 +1,25 @@ import random +from os import urandom from asyncpg import UniqueViolationError from quart import Blueprint, jsonify, request, current_app as app +from logbook import Logger -from ..auth import token_check -from ..errors import Forbidden, BadRequest +from ..errors import Forbidden, BadRequest, Unauthorized from ..schemas import validate, USER_UPDATE, GET_MENTIONS from .guilds import guild_check -from .auth import check_password -from litecord.auth import hash_data, check_username_usage +from litecord.auth import token_check, hash_data, check_username_usage from litecord.blueprints.guild.mod import remove_member from litecord.enums import PremiumType from litecord.images import parse_data_uri from litecord.permissions import base_permissions +from litecord.blueprints.auth import check_password + bp = Blueprint('user', __name__) +log = Logger(__name__) async def mass_user_update(user_id, app_=None): @@ -450,3 +453,102 @@ async def _get_mentions(): ) return jsonify(res) + + +def rand_hex(length: int = 8) -> str: + """Generate random hex characters.""" + return urandom(length).hex()[:length] + + +async def _del_from_table(table: str, user_id: int): + column = { + 'channel_overwrites': 'target_user', + 'user_settings': 'id' + }.get(table, 'user_id') + + res = await app.db.execute(f""" + DELETE FROM {table} + WHERE {column} = $1 + """, user_id) + + log.info('Deleting uid {} from {}, res: {!r}', + user_id, table, res) + + +@bp.route('/users/@me/delete') +async def delete_account(): + """Delete own account. + + There isn't any inherent need to dispatch + events to connected users, so this is mostly + DB operations. + """ + user_id = await token_check() + + j = await request.get_json() + + try: + password = j['password'] + except KeyError: + raise BadRequest('password required') + + owned_guilds = await app.db.fetchval(""" + SELECT COUNT(*) + FROM guilds + WHERE owner_id = $1 + """, user_id) + + if owned_guilds > 0: + raise BadRequest('You still own guilds.') + + pwd_hash = await app.db.fetchval(""" + SELECT password_hash + FROM users + WHERE id = $1 + """, user_id) + + if not await check_password(pwd_hash, password): + raise Unauthorized('password does not match') + + new_username = f'Deleted User {rand_hex()}' + + await app.db.execute(""" + UPDATE users + SET + username = $1, + email = NULL, + mfa_enabled = false, + verified = false + avatar = NULL, + flags = 0, + premium_since = NULL, + phone = '', + password_hash = '123' + WHERE + id = $2 + """, new_username, user_id) + + # remove the user from various tables + await _del_from_table('user_settings', user_id) + await _del_from_table('user_payment_sources', user_id) + await _del_from_table('user_subscriptions', user_id) + await _del_from_table('user_payments', user_id) + await _del_from_table('user_read_state', user_id) + await _del_from_table('guild_settings', user_id) + await _del_from_table('guild_settings_channel_overrides', user_id) + + await app.db.execute(""" + DELETE FROM relationships + WHERE user_id = $1 OR peer_id = $1 + """, user_id) + + # DMs are still maintained, but not the state. + await _del_from_table('dm_channel_state', user_id) + + # TODO: group DMs + + await _del_from_table('members', user_id) + await _del_from_table('member_roles', user_id) + await _del_from_table('channel_overwrites', user_id) + + return '', 204 diff --git a/litecord/pubsub/guild.py b/litecord/pubsub/guild.py index c687d25..d86edd3 100644 --- a/litecord/pubsub/guild.py +++ b/litecord/pubsub/guild.py @@ -26,8 +26,6 @@ class GuildDispatcher(DispatcherWithState): user_id, chan_id, storage=self.main_dispatcher.app.storage) - print(user_id, chan_id, chan_perms.bits.read_messages) - if not chan_perms.bits.read_messages: log.debug('skipping cid={}, no read messages', chan_id) diff --git a/litecord/pubsub/lazy_guild.py b/litecord/pubsub/lazy_guild.py index fcbd153..ef7c65e 100644 --- a/litecord/pubsub/lazy_guild.py +++ b/litecord/pubsub/lazy_guild.py @@ -13,6 +13,7 @@ from litecord.permissions import ( Permissions, overwrite_find_mix, get_permissions, role_permissions ) from litecord.utils import index_by_func +from litecord.utils import mmh3 log = Logger(__name__) @@ -267,7 +268,31 @@ class GuildMemberList: """get the id of the member list.""" return ('everyone' if self.channel_id == self.guild_id - else str(self.channel_id)) + else self._calculated_id) + + @property + def _calculated_id(self): + """Calculate an id used by the client.""" + + if not self.list: + return str(self.channel_id) + + # list of strings holding the hash input + ovs_i = [] + + for actor_id, overwrite in self.list.overwrites.items(): + allow, deny = ( + Permissions(overwrite['allow']), + Permissions(overwrite['deny']) + ) + + if allow.bits.read_messages: + ovs_i.append(f'allow:{actor_id}') + elif deny.bits.read_messages: + ovs_i.append(f'deny:{actor_id}') + + hash_in = ','.join(ovs_i) + return str(mmh3(hash_in)) def _set_empty_list(self): """Set the member list as being empty.""" diff --git a/litecord/utils.py b/litecord/utils.py index 2fda9d5..26c6ba2 100644 --- a/litecord/utils.py +++ b/litecord/utils.py @@ -37,3 +37,76 @@ def index_by_func(function, indexable: iter) -> int: return index return None + + +def _u(val): + """convert to unsigned.""" + return val % 0x100000000 + + +def mmh3(key: str, seed: int = 0): + """MurMurHash3 implementation. + + This seems to match Discord's JavaScript implementaiton. + + Based off + https://github.com/garycourt/murmurhash-js/blob/master/murmurhash3_gc.js + """ + key = [ord(c) for c in key] + + remainder = len(key) & 3 + bytecount = len(key) - remainder + h1 = seed + + # mm3 constants + c1 = 0xcc9e2d51 + c2 = 0x1b873593 + i = 0 + + while i < bytecount: + k1 = ( + (key[i] & 0xff) | + ((key[i + 1] & 0xff) << 8) | + ((key[i + 2] & 0xff) << 16) | + ((key[i + 3] & 0xff) << 24) + ) + + i += 4 + + k1 = ((((k1 & 0xffff) * c1) + ((((_u(k1) >> 16) * c1) & 0xffff) << 16))) & 0xffffffff + k1 = (k1 << 15) | (_u(k1) >> 17) + k1 = ((((k1 & 0xffff) * c2) + ((((_u(k1) >> 16) * c2) & 0xffff) << 16))) & 0xffffffff; + + h1 ^= k1 + h1 = (h1 << 13) | (_u(h1) >> 19); + h1b = ((((h1 & 0xffff) * 5) + ((((_u(h1) >> 16) * 5) & 0xffff) << 16))) & 0xffffffff; + h1 = (((h1b & 0xffff) + 0x6b64) + ((((_u(h1b) >> 16) + 0xe654) & 0xffff) << 16)) + + + k1 = 0 + v = None + + if remainder == 3: + v = (key[i + 2] & 0xff) << 16 + elif remainder == 2: + v = (key[i + 1] & 0xff) << 8 + elif remainder == 1: + v = (key[i] & 0xff) + + if v is not None: + k1 ^= v + + k1 = (((k1 & 0xffff) * c1) + ((((_u(k1) >> 16) * c1) & 0xffff) << 16)) & 0xffffffff + k1 = (k1 << 15) | (_u(k1) >> 17) + k1 = (((k1 & 0xffff) * c2) + ((((_u(k1) >> 16) * c2) & 0xffff) << 16)) & 0xffffffff + h1 ^= k1 + + h1 ^= len(key) + + h1 ^= _u(h1) >> 16 + h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((_u(h1) >> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff + h1 ^= _u(h1) >> 13 + h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((_u(h1) >> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff + h1 ^= _u(h1) >> 16 + + return _u(h1) >> 0 diff --git a/static/logo/logo.png b/static/logo/logo.png new file mode 100644 index 0000000..d0f3fd9 Binary files /dev/null and b/static/logo/logo.png differ diff --git a/static/logo/logo.svg b/static/logo/logo.svg new file mode 100644 index 0000000..7f451a7 --- /dev/null +++ b/static/logo/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/logo/logo@2x.png b/static/logo/logo@2x.png new file mode 100644 index 0000000..0ce35d4 Binary files /dev/null and b/static/logo/logo@2x.png differ