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].
[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

View File

@ -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,
})

View File

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

View File

@ -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/<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',
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
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

View File

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

View File

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

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]:
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'],

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 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, '

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()