diff --git a/README.md b/README.md index f471d33..4658055 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Python. This project is a rewrite of [litecord-reference]. -[litecord-reference]: https://gitlab.com/lnmds/litecord-reference +[litecord-reference]: https://gitlab.com/luna/litecord-reference ## Install @@ -19,6 +19,8 @@ This project is a rewrite of [litecord-reference]. $ git clone https://gitlab.com/luna/litecord.git && cd litecord # Setup the database: +# don't forget that you can create a specific +# postgres user just for the litecord database $ createdb litecord $ psql -f schema.sql litecord diff --git a/litecord/blueprints/auth.py b/litecord/blueprints/auth.py index f46703d..c275835 100644 --- a/litecord/blueprints/auth.py +++ b/litecord/blueprints/auth.py @@ -87,3 +87,10 @@ async def login(): return jsonify({ 'token': make_token(user_id, pwd_hash) }) + + +@bp.route('/consent-required', methods=['GET']) +async def consent_required(): + return jsonify({ + 'required': True, + }) diff --git a/litecord/blueprints/guilds.py b/litecord/blueprints/guilds.py index c8daca0..d5782ff 100644 --- a/litecord/blueprints/guilds.py +++ b/litecord/blueprints/guilds.py @@ -83,10 +83,7 @@ async def create_guild(): # TODO: j['roles'] and j['channels'] - guild_json = await app.storage.get_guild(guild_id, user_id) - guild_extra = await app.storage.get_guild_extra(guild_id, user_id, 250) - - guild_total = {**guild_json, **guild_extra} + guild_total = await app.storage.get_guild_full(guild_id, user_id, 250) app.dispatcher.sub_guild(guild_id, user_id) await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild_total) diff --git a/litecord/blueprints/invites.py b/litecord/blueprints/invites.py index ceacd65..f3cae8b 100644 --- a/litecord/blueprints/invites.py +++ b/litecord/blueprints/invites.py @@ -7,8 +7,10 @@ from logbook import Logger from ..auth import token_check from ..schemas import validate, INVITE from ..enums import ChannelType -from ..errors import BadRequest +from ..errors import BadRequest, Forbidden from .channels import channel_check +from .guilds import guild_check +from ..utils import async_map log = Logger(__name__) bp = Blueprint('invites', __name__) @@ -28,7 +30,7 @@ async def create_invite(channel_id): ChannelType.GUILD_VOICE.value): raise BadRequest('Invalid channel type') - invite_code = hashlib.md5(os.urandom(64)).hexdigest()[:16] + invite_code = hashlib.md5(os.urandom(64)).hexdigest()[:6] await app.db.execute( """ @@ -54,3 +56,149 @@ async def get_invite(invite_code: str): inv.update(extra) return jsonify(inv) + + +@bp.route('/invite/', methods=['GET']) +async def get_invite_2(invite_code: str): + return await get_invite(invite_code) + + +@bp.route('/invites/', methods=['DELETE']) +async def delete_invite(invite_code: str): + user_id = await token_check() + + gid = await app.db.fetchval(""" + SELECT guild_id + FROM invites + WHERE code = $1 + """, invite_code) + + if gid is None: + raise BadRequest('Unknown invite') + + # TODO: check MANAGE_CHANNELS permission + # for now we'll go with checking owner + + owner_id = await app.db.fetchval(""" + SELECT owner_id + FROM guilds + WHERE id = $1 + """. gid) + + if owner_id != user_id: + raise Forbidden('Not guild owner') + + inv = await app.storage.get_invite(invite_code) + + await app.db.fetchval(""" + DELETE FROM invites + WHERE code = $1 + """, invite_code) + + return jsonify(inv) + + +async def _get_inv(code): + inv = await app.storage.get_invite(code) + meta = await app.storage.get_invite_metadata(code) + return {**inv, **meta} + + +@bp.route('/guilds//invites', methods=['GET']) +async def get_guild_invites(guild_id: int): + user_id = await token_check() + await guild_check(user_id, guild_id) + + inv_codes = await app.db.fetch(""" + SELECT code + FROM invites + WHERE guild_id = $1 + """, guild_id) + + # TODO: MANAGE_GUILD permission + + inv_codes = [r['code'] for r in inv_codes] + invs = await async_map(_get_inv, inv_codes) + return jsonify(invs) + + +@bp.route('/channels//invites', methods=['GET']) +async def get_channel_invites(channel_id: int): + user_id = await token_check + guild_id = await channel_check(user_id, channel_id) + + inv_codes = await app.db.fetch(""" + SELECT code + FROM invites + WHERE guild_id = $1 AND channel_id = $2 + """, guild_id, channel_id) + + # TODO: check MANAGE_CHANNELS permission + + inv_codes = [r['code'] for r in inv_codes] + invs = await async_map(_get_inv, inv_codes) + return jsonify(invs) + + +@bp.route('/invite/', methods=['POST']) +async def use_invite(invite_code): + user_id = await token_check() + + guild_id = await app.db.fetchval(""" + SELECT guild_id + FROM invites + WHERE code = $1 + """, invite_code) + + if not guild_id: + raise BadRequest('Guild not Found') + + joined = await app.db.fetchval(""" + SELECT joined_at + FROM members + WHERE user_id = $1 AND guild_id = $2 + """, user_id, guild_id) + + if joined is not None: + raise BadRequest('You are already in the guild') + + await app.db.execute(""" + INSERT INTO members (user_id, guild_id) + VALUES ($1, $2) + """, user_id, guild_id) + + # add the @everyone role to the invited member + await app.db.execute(""" + INSERT INTO member_roles (user_id, guild_id, role_id) + VALUES ($1, $2, $3) + """, user_id, guild_id, guild_id) + + # tell current members a new member came up + member = await app.storage.get_member_data_one(guild_id, user_id) + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_MEMBER_ADD', { + **member, + **{ + 'guild_id': str(guild_id), + }, + }) + + # subscribe new member to guild, so they get events n stuff + app.dispatcher.sub_guild(guild_id, user_id) + + # tell the new member that theres the guild it just joined. + # we use dispatch_user_guild so that we send the GUILD_CREATE + # just to the shards that are actually tied to it. + guild = await app.storage.get_guild_full(guild_id, user_id, 250) + await app.dispatcher.dispatch_user_guild( + user_id, guild_id, 'GUILD_CREATE', guild) + + # the reply is an invite object for some reason. + inv = await app.storage.get_invite(invite_code) + inv_meta = await app.storage.get_invite_metadata(invite_code) + + return jsonify({ + **inv, + **{ + 'inviter': inv_meta['inviter'] + } + }) diff --git a/litecord/dispatcher.py b/litecord/dispatcher.py index 128fa94..e949bf6 100644 --- a/litecord/dispatcher.py +++ b/litecord/dispatcher.py @@ -58,3 +58,26 @@ class EventDispatcher: log.info('Dispatched {} {!r} to {} states', guild_id, event_name, dispatched) + + async def _dispatch_states(self, states: list, event: str, data: Any): + for state in states: + await state.ws.dispatch(event, data) + + async def dispatch_user_guild(self, user_id: int, guild_id: int, + event: str, data: Any): + """Dispatch a single event to a user inside a guild. + + The difference between dispatch_user and dispatch_user_guild + is sharding management happening here, via StateManager.fetch_states + """ + states = self.state_manager.fetch_states(user_id, guild_id) + + if not states: + self.unsub_guild(guild_id, user_id) + + await self._dispatch_states(states, event, data) + + async def dispatch_user(self, user_id: int, event: str, data: Any): + """Dispatch an event to a single user.""" + states = self.state_manager.user_states(user_id) + await self._dispatch_states(states, event, data) diff --git a/litecord/gateway/state_manager.py b/litecord/gateway/state_manager.py index 1407316..ea89f5b 100644 --- a/litecord/gateway/state_manager.py +++ b/litecord/gateway/state_manager.py @@ -52,18 +52,37 @@ class StateManager: return states + def user_states(self, user_id: int) -> List[GatewayState]: + """Fetch all states tied to a single user.""" + return list(self.states[user_id].values()) + def guild_states(self, member_ids: List[int], guild_id: int) -> List[GatewayState]: + """Fetch all possible states about members in a guild.""" states = [] for member_id in member_ids: member_states = self.fetch_states(member_id, guild_id) - # for now, just get the first state - try: - state = next(iter(member_states)) - states.append(state) - except StopIteration: - pass + # member_states is empty if the user never logged in + # since server start, so we need to add a dummy state + if not member_states: + dummy_state = GatewayState( + session_id='', + user_id=member_id, + presence={ + 'afk': False, + 'status': 'offline', + 'game': None, + 'since': 0 + } + ) + + states.append(dummy_state) + continue + + # push all available member states to the result + # array + states.extend(member_states) return states diff --git a/litecord/gateway/websocket.py b/litecord/gateway/websocket.py index 557c57a..aa745c6 100644 --- a/litecord/gateway/websocket.py +++ b/litecord/gateway/websocket.py @@ -599,12 +599,11 @@ class GatewayWebsocket: if guild_id not in gids: return - members = await self.storage.get_member_data(guild_id) - member_ids = [int(m['user']['id']) for m in members] + member_ids = await self.storage.get_member_ids(guild_id) # the current implementation is rudimentary and only # generates two groups: online and offline, using - # guild_presences for list_data. + # PresenceManager.guild_presences to fill list_data. # this also doesn't take account the channels in lazy_request. diff --git a/litecord/schemas.py b/litecord/schemas.py index 607c6ea..a7ce4d0 100644 --- a/litecord/schemas.py +++ b/litecord/schemas.py @@ -52,10 +52,10 @@ class LitecordValidator(Validator): def validate(reqjson, schema, raise_err: bool = True): validator = LitecordValidator(schema) - log.debug('Validating {}', reqjson) if not validator.validate(reqjson): errs = validator.errors + log.warning('Error validating doc: {!r}', errs) if raise_err: raise BadRequest('bad payload', errs) diff --git a/litecord/storage.py b/litecord/storage.py index 00698ff..d7bcd36 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -323,6 +323,13 @@ class Storage: ), }} + async def get_guild_full(self, guild_id: int, + user_id: int, large_count: int = 250) -> Dict: + guild = await self.get_guild(guild_id, user_id) + extra = await self.get_guild_extra(guild_id, user_id, large_count) + + return {**guild, **extra} + async def get_member_ids(self, guild_id: int) -> List[int]: rows = await self.db.fetch(""" SELECT user_id @@ -421,13 +428,16 @@ class Storage: # fetch some guild info guild = await self.db.fetchrow(""" - SELECT id::text, name, splash, icon + SELECT id::text, name, splash, icon, verification_level FROM guilds WHERE id = $1 """, invite['guild_id']) dinv['guild'] = dict(guild) + # TODO: query actual guild features + dinv['guild']['features'] = [] + chan = await self.get_channel(invite['channel_id']) dinv['channel'] = { 'id': chan['id'], diff --git a/litecord/utils.py b/litecord/utils.py new file mode 100644 index 0000000..1a8aefb --- /dev/null +++ b/litecord/utils.py @@ -0,0 +1,9 @@ +async def async_map(function, iterable) -> list: + """Map a coroutine to an iterable.""" + res = [] + + for element in iterable: + result = await function(element) + res.append(result) + + return res diff --git a/run.py b/run.py index 755a752..713769a 100644 --- a/run.py +++ b/run.py @@ -5,7 +5,7 @@ import asyncpg import logbook import logging import websockets -from quart import Quart, g, jsonify +from quart import Quart, g, jsonify, request from logbook import StreamHandler, Logger from logbook.compat import redirect_logging @@ -64,7 +64,7 @@ for bp, suffix in bps.items(): @app.after_request async def app_after_request(resp): - resp.headers['Access-Control-Allow-Origin'] = 'https://ptb.discordapp.com' + resp.headers['Access-Control-Allow-Origin'] = request.headers['Origin'] resp.headers['Access-Control-Allow-Headers'] = ('*, X-Super-Properties, ' 'X-Fingerprint, ' 'X-Context-Properties, ' diff --git a/utils/useradd.py b/utils/useradd.py new file mode 100755 index 0000000..6561ae3 --- /dev/null +++ b/utils/useradd.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import sys +import requests + +def main(): + argv = sys.argv + inst_url = 'https://discordapp.io' + + if len(argv) < 4: + print('useradd.py ') + return + + email, username, password = sys.argv[1:4] + print('email', repr(email)) + print('username', repr(username)) + print('password', repr(password)) + resp = requests.post(f'{inst_url}/api/v6/auth/register', json={ + 'email': email, + 'password': password, + 'username': username, + }, headers={ + 'Origin': inst_url + }) + print(resp.status_code) + print(resp.text) + + +if __name__ == '__main__': + main()