add invite usage

users can now join guilds!

invites:
 - add GET /api/v6/invites/<invite_code>
 - add DELETE /api/v6/invites/<invite_code>
 - add GET /api/v6/guilds/<guild_id>/invites
 - add GET /api/v6/channels/<channel_id>/invites
 - add POST /api/v6/invite/<invite_code>

storage:
 - add verification_level to invites
 - add empty invite.guild.features

gateway.state_manager:
 - add StateManager.user_states
 - give a dummy offline state on guild_states
    - this makes it possible for people to see offline members even when
       those members never logged in (since the would have no state being
       reffered to them)

 - gateway.websocket: use get_member_ids on lazy guild handler
 - auth: add GET /api/v6/auth/consent-required
 - dispatcher: add dispatch_user_guild and dispatch_user
 - run: use Origin header on Access-Control-Allow-Origin
This commit is contained in:
Luna Mendes 2018-09-27 23:10:30 -03:00
parent f346a991c5
commit 92f6e3cf75
12 changed files with 263 additions and 20 deletions

View File

@ -5,7 +5,7 @@ Python.
This project is a rewrite of [litecord-reference]. 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 ## Install
@ -19,6 +19,8 @@ This project is a rewrite of [litecord-reference].
$ git clone https://gitlab.com/luna/litecord.git && cd litecord $ git clone https://gitlab.com/luna/litecord.git && cd litecord
# Setup the database: # Setup the database:
# don't forget that you can create a specific
# postgres user just for the litecord database
$ createdb litecord $ createdb litecord
$ psql -f schema.sql litecord $ psql -f schema.sql litecord

View File

@ -87,3 +87,10 @@ async def login():
return jsonify({ return jsonify({
'token': make_token(user_id, pwd_hash) 'token': make_token(user_id, pwd_hash)
}) })
@bp.route('/consent-required', methods=['GET'])
async def consent_required():
return jsonify({
'required': True,
})

View File

@ -83,10 +83,7 @@ async def create_guild():
# TODO: j['roles'] and j['channels'] # TODO: j['roles'] and j['channels']
guild_json = await app.storage.get_guild(guild_id, user_id) guild_total = await app.storage.get_guild_full(guild_id, user_id, 250)
guild_extra = await app.storage.get_guild_extra(guild_id, user_id, 250)
guild_total = {**guild_json, **guild_extra}
app.dispatcher.sub_guild(guild_id, user_id) app.dispatcher.sub_guild(guild_id, user_id)
await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild_total) await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild_total)

View File

@ -7,8 +7,10 @@ from logbook import Logger
from ..auth import token_check from ..auth import token_check
from ..schemas import validate, INVITE from ..schemas import validate, INVITE
from ..enums import ChannelType from ..enums import ChannelType
from ..errors import BadRequest from ..errors import BadRequest, Forbidden
from .channels import channel_check from .channels import channel_check
from .guilds import guild_check
from ..utils import async_map
log = Logger(__name__) log = Logger(__name__)
bp = Blueprint('invites', __name__) bp = Blueprint('invites', __name__)
@ -28,7 +30,7 @@ async def create_invite(channel_id):
ChannelType.GUILD_VOICE.value): ChannelType.GUILD_VOICE.value):
raise BadRequest('Invalid channel type') 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( await app.db.execute(
""" """
@ -54,3 +56,149 @@ async def get_invite(invite_code: str):
inv.update(extra) inv.update(extra)
return jsonify(inv) return jsonify(inv)
@bp.route('/invite/<invite_code>', methods=['GET'])
async def get_invite_2(invite_code: str):
return await get_invite(invite_code)
@bp.route('/invites/<invite_code>', 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/<int:guild_id>/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/<int:channel_id>/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/<invite_code>', 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']
}
})

View File

@ -58,3 +58,26 @@ class EventDispatcher:
log.info('Dispatched {} {!r} to {} states', log.info('Dispatched {} {!r} to {} states',
guild_id, event_name, dispatched) 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)

View File

@ -52,18 +52,37 @@ class StateManager:
return states 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], def guild_states(self, member_ids: List[int],
guild_id: int) -> List[GatewayState]: guild_id: int) -> List[GatewayState]:
"""Fetch all possible states about members in a guild."""
states = [] states = []
for member_id in member_ids: for member_id in member_ids:
member_states = self.fetch_states(member_id, guild_id) member_states = self.fetch_states(member_id, guild_id)
# for now, just get the first state # member_states is empty if the user never logged in
try: # since server start, so we need to add a dummy state
state = next(iter(member_states)) if not member_states:
states.append(state) dummy_state = GatewayState(
except StopIteration: session_id='',
pass 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 return states

View File

@ -599,12 +599,11 @@ class GatewayWebsocket:
if guild_id not in gids: if guild_id not in gids:
return return
members = await self.storage.get_member_data(guild_id) member_ids = await self.storage.get_member_ids(guild_id)
member_ids = [int(m['user']['id']) for m in members]
# the current implementation is rudimentary and only # the current implementation is rudimentary and only
# generates two groups: online and offline, using # 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. # this also doesn't take account the channels in lazy_request.

View File

@ -52,10 +52,10 @@ class LitecordValidator(Validator):
def validate(reqjson, schema, raise_err: bool = True): def validate(reqjson, schema, raise_err: bool = True):
validator = LitecordValidator(schema) validator = LitecordValidator(schema)
log.debug('Validating {}', reqjson)
if not validator.validate(reqjson): if not validator.validate(reqjson):
errs = validator.errors errs = validator.errors
log.warning('Error validating doc: {!r}', errs)
if raise_err: if raise_err:
raise BadRequest('bad payload', errs) raise BadRequest('bad payload', errs)

View File

@ -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]: async def get_member_ids(self, guild_id: int) -> List[int]:
rows = await self.db.fetch(""" rows = await self.db.fetch("""
SELECT user_id SELECT user_id
@ -421,13 +428,16 @@ class Storage:
# fetch some guild info # fetch some guild info
guild = await self.db.fetchrow(""" guild = await self.db.fetchrow("""
SELECT id::text, name, splash, icon SELECT id::text, name, splash, icon, verification_level
FROM guilds FROM guilds
WHERE id = $1 WHERE id = $1
""", invite['guild_id']) """, invite['guild_id'])
dinv['guild'] = dict(guild) dinv['guild'] = dict(guild)
# TODO: query actual guild features
dinv['guild']['features'] = []
chan = await self.get_channel(invite['channel_id']) chan = await self.get_channel(invite['channel_id'])
dinv['channel'] = { dinv['channel'] = {
'id': chan['id'], 'id': chan['id'],

9
litecord/utils.py Normal file
View File

@ -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

4
run.py
View File

@ -5,7 +5,7 @@ import asyncpg
import logbook import logbook
import logging import logging
import websockets import websockets
from quart import Quart, g, jsonify from quart import Quart, g, jsonify, request
from logbook import StreamHandler, Logger from logbook import StreamHandler, Logger
from logbook.compat import redirect_logging from logbook.compat import redirect_logging
@ -64,7 +64,7 @@ for bp, suffix in bps.items():
@app.after_request @app.after_request
async def app_after_request(resp): 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, ' resp.headers['Access-Control-Allow-Headers'] = ('*, X-Super-Properties, '
'X-Fingerprint, ' 'X-Fingerprint, '
'X-Context-Properties, ' 'X-Context-Properties, '

29
utils/useradd.py Executable file
View File

@ -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 <email> <username> <password>')
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()