diff --git a/docs/admin_api.md b/docs/admin_api.md index d19658f..1627b57 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -48,6 +48,26 @@ Output: | old | user object | old user object pre-delete | | new | user object | new user object post-delete | +### PATCH `/users/` + +Update a single user's information. + +Returns a user object on success. + +**Note:** You can not change any user's staff badge state (neither adding +it or removing it) to not cause privilege escalation/de-escalation (where +a staff makes more staff or a staff removes staff privileges of someone else). +Keep in mind the staff badge is what grants access to the Admin API, so. + +**Note:** Changing a user's nitro badge is not defined via the flags. +Plus that would require adding an interface to user payments +through the Admin API. + +[UserFlags]: https://discordapp.com/developers/docs/resources/user#user-object-user-flags + +| field | type | description | +| --: | :-- | :-- | +| flags | [UserFlags] | user flags/badges | ## Instance invites @@ -130,7 +150,22 @@ Returns empty body with 204 status code on success. ### GET `/guilds/` -Returns a partial guild object. +Returns a partial guild object. Gives a 404 when the guild is not found. + +### PATCH `/guilds/` + +Update a single guild. +Dispatches `GUILD_UPDATE` to subscribers of the guild. + +Returns a guild object or an unavailable guild object on success. + +| field | type | description | +| --: | :-- | :-- | +| unavailable | bool | if the guild is unavailable | + +### DELETE `/guilds/` + +Delete a single guild. Returns 204 on success. ## Guild features diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index d4dba63..22d6bdb 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -17,7 +17,7 @@ along with this program. If not, see . """ -from litecord.enums import Feature +from litecord.enums import Feature, UserFlags VOICE_SERVER = { 'hostname': {'type': 'string', 'maxlength': 255, 'required': True} @@ -50,3 +50,11 @@ USER_CREATE = { INSTANCE_INVITE = { 'max_uses': {'type': 'integer', 'required': True} } + +GUILD_UPDATE = { + 'unavailable': {'type': 'boolean', 'required': False} +} + +USER_UPDATE = { + 'flags': {'required': False, 'coerce': UserFlags.from_int} +} diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py index d779c02..e27f544 100644 --- a/litecord/blueprints/admin_api/guilds.py +++ b/litecord/blueprints/admin_api/guilds.py @@ -17,9 +17,13 @@ along with this program. If not, see . """ -from quart import Blueprint, jsonify, current_app as app +from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check +from litecord.schemas import validate +from litecord.admin_schemas import GUILD_UPDATE +from litecord.blueprints.guilds import delete_guild +from litecord.errors import GuildNotFound bp = Blueprint('guilds_admin', __name__) @@ -28,6 +32,47 @@ async def get_guild(guild_id: int): """Get a basic guild payload.""" await admin_check() - return jsonify( - await app.storage.get_guild(guild_id) - ) + guild = await app.storage.get_guild(guild_id) + + if not guild: + raise GuildNotFound() + + return jsonify(guild) + + +@bp.route('/', methods=['PATCH']) +async def update_guild(guild_id: int): + await admin_check() + + j = validate(await request.get_json(), GUILD_UPDATE) + + # TODO: what happens to the other guild attributes when its + # unavailable? do they vanish? + old_unavailable = app.guild_store.get(guild_id, 'unavailable') + new_unavailable = j.get('unavailable', old_unavailable) + + # always set unavailable status since new_unavailable will be + # old_unavailable when not provided, so we don't need to check if + # j.unavailable is there + app.guild_store.set(guild_id, 'unavailable', j['unavailable']) + + guild = await app.storage.get_guild(guild_id) + + # TODO: maybe we can just check guild['unavailable']...? + + if old_unavailable and not new_unavailable: + # guild became available + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild) + else: + # guild became unavailable + await app.dispatcher.dispatch_guild(guild_id, 'GUILD_DELETE', guild) + + return jsonify(guild) + + +@bp.route('/', methods=['DELETE']) +async def delete_guild_as_admin(guild_id): + """Delete a single guild via the admin API without ownership checks.""" + await admin_check() + await delete_guild(guild_id) + return '', 204 diff --git a/litecord/blueprints/admin_api/users.py b/litecord/blueprints/admin_api/users.py index 6f309d6..ab3ba37 100644 --- a/litecord/blueprints/admin_api/users.py +++ b/litecord/blueprints/admin_api/users.py @@ -22,10 +22,13 @@ from quart import Blueprint, jsonify, current_app as app, request from litecord.auth import admin_check from litecord.blueprints.auth import create_user from litecord.schemas import validate -from litecord.admin_schemas import USER_CREATE -from litecord.errors import BadRequest +from litecord.admin_schemas import USER_CREATE, USER_UPDATE +from litecord.errors import BadRequest, Forbidden from litecord.utils import async_map -from litecord.blueprints.users import delete_user, user_disconnect +from litecord.blueprints.users import ( + delete_user, user_disconnect, mass_user_update +) +from litecord.enums import UserFlags bp = Blueprint('users_admin', __name__) @@ -116,3 +119,31 @@ async def _delete_single_user(user_id: int): 'old': old_user, 'new': new_user }) + + +@bp.route('/', methods=['PATCH']) +async def patch_user(user_id: int): + await admin_check() + + j = validate(await request.get_json(), USER_UPDATE) + + # get the original user for flags checking + user = await app.storage.get_user(user_id) + old_flags = UserFlags.from_int(user['flags']) + + # j.flags is already a UserFlags since we coerce it. + if 'flags' in j: + new_flags = j['flags'] + + # disallow any changes to the staff badge + if new_flags.is_staff != old_flags.is_staff: + raise Forbidden('you can not change a users staff badge') + + await app.db.execute(""" + UPDATE users + SET flags = $1 + WHERE id = $2 + """, new_flags.value, user_id) + + public_user, _ = await mass_user_update(user_id, app) + return jsonify(public_user) diff --git a/litecord/blueprints/guilds.py b/litecord/blueprints/guilds.py index 781b1be..0bc7585 100644 --- a/litecord/blueprints/guilds.py +++ b/litecord/blueprints/guilds.py @@ -350,21 +350,17 @@ async def _update_guild(guild_id): return jsonify(guild) -@bp.route('/', methods=['DELETE']) -# this endpoint is not documented, but used by the official client. -@bp.route('//delete', methods=['POST']) -async def delete_guild(guild_id): - """Delete a guild.""" - user_id = await token_check() - await guild_owner_check(user_id, guild_id) +async def delete_guild(guild_id: int, *, app_=None): + """Delete a single guild.""" + app_ = app_ or app - await app.db.execute(""" + await app_.db.execute(""" DELETE FROM guilds WHERE guilds.id = $1 """, guild_id) # Discord's client expects IDs being string - await app.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', { + await app_.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', { 'guild_id': str(guild_id), 'id': str(guild_id), # 'unavailable': False, @@ -373,8 +369,17 @@ async def delete_guild(guild_id): # remove from the dispatcher so nobody # becomes the little memer that tries to fuck up with # everybody's gateway - await app.dispatcher.remove('guild', guild_id) + await app_.dispatcher.remove('guild', guild_id) + +@bp.route('/', methods=['DELETE']) +# this endpoint is not documented, but used by the official client. +@bp.route('//delete', methods=['POST']) +async def delete_guild_handler(guild_id): + """Delete a guild.""" + user_id = await token_check() + await guild_owner_check(user_id, guild_id) + await delete_guild(guild_id) return '', 204 diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index a3d7672..bea5b89 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -83,7 +83,7 @@ async def mass_user_update(user_id, app_=None): 'lazy_guild', guild_ids, 'update_user', user_id ) - return private_user + return public_user, private_user @bp.route('/@me', methods=['GET']) @@ -257,7 +257,7 @@ async def patch_me(): user.pop('password_hash') - private_user = await mass_user_update(user_id, app) + _, private_user = await mass_user_update(user_id, app) return jsonify(private_user) diff --git a/litecord/enums.py b/litecord/enums.py index 52cd742..98e036c 100644 --- a/litecord/enums.py +++ b/litecord/enums.py @@ -58,6 +58,8 @@ class Flags: def _make_int(value): res = Flags() + setattr(res, 'value', value) + for attr, val in attrs: # get only the ones that represent a field in the # number's bits diff --git a/litecord/guild_memory_store.py b/litecord/guild_memory_store.py new file mode 100644 index 0000000..a2e3457 --- /dev/null +++ b/litecord/guild_memory_store.py @@ -0,0 +1,35 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +class GuildMemoryStore: + """Store in-memory properties about guilds. + + I could have just used Redis... probably too overkill to add + aioredis to the already long depedency list, plus, I don't need + """ + def __init__(self): + self._store = {} + + def get(self, guild_id: int, attribute: str, default=None): + """get a key""" + return self._store.get(f'{guild_id}:{attribute}', default) + + def set(self, guild_id: int, attribute: str, value): + """set a key""" + self._store[f'{guild_id}:{attribute}'] = value diff --git a/litecord/storage.py b/litecord/storage.py index 3446fe2..79d07dd 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -180,6 +180,18 @@ class Storage: drow = dict(row) + # a guild's unavailable state is kept in memory, and we remove every + # other guild related field when its unavailable. + drow['unavailable'] = self.app.guild_store.get( + guild_id, 'unavailable', False) + + if drow['unavailable']: + drow = { + 'id': drow['id'], + 'unavailable': True + } + + # guild.owner is dependant of the user doing the get_guild call. if user_id: drow['owner'] = drow['owner_id'] == str(user_id) @@ -663,6 +675,9 @@ class Storage: if guild is None: return None + if guild['unavailable']: + return guild + extra = await self.get_guild_extra(guild_id, user_id, large_count) return {**guild, **extra} diff --git a/run.py b/run.py index ef5e118..d851fdc 100644 --- a/run.py +++ b/run.py @@ -75,6 +75,7 @@ from litecord.presence import PresenceManager from litecord.images import IconManager from litecord.jobs import JobManager from litecord.voice.manager import VoiceManager +from litecord.guild_memory_store import GuildMemoryStore from litecord.gateway.gateway import websocket_handler @@ -246,6 +247,7 @@ def init_app_managers(app_): app_.storage.presence = app_.presence app_.voice = VoiceManager(app_) + app_.guild_store = GuildMemoryStore() async def api_index(app_): diff --git a/tests/test_admin_api/test_guilds.py b/tests/test_admin_api/test_guilds.py new file mode 100644 index 0000000..e3183f5 --- /dev/null +++ b/tests/test_admin_api/test_guilds.py @@ -0,0 +1,137 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +import secrets + +import pytest + +from tests.common import login +from litecord.blueprints.guilds import delete_guild +from litecord.errors import GuildNotFound + +async def _create_guild(test_cli, *, token=None): + token = token or await login('admin', test_cli) + + genned_name = secrets.token_hex(6) + + resp = await test_cli.post('/api/v6/guilds', headers={ + 'Authorization': token + }, json={ + 'name': genned_name, + 'region': None + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['name'] == genned_name + + return rjson + + +async def _fetch_guild(test_cli, guild_id, *, token=None, ret_early=False): + token = token or await login('admin', test_cli) + + resp = await test_cli.get(f'/api/v6/admin/guilds/{guild_id}', headers={ + 'Authorization': token + }) + + if ret_early: + return resp + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['id'] == guild_id + + return rjson + + +@pytest.mark.asyncio +async def test_guild_fetch(test_cli): + """Test the creation and fetching of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + + try: + await _fetch_guild(test_cli, guild_id) + finally: + await delete_guild(int(guild_id), app_=test_cli.app) + + +@pytest.mark.asyncio +async def test_guild_update(test_cli): + """Test the update of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + assert not rjson['unavailable'] + + try: + # I believe setting up an entire gateway client registered to the guild + # would be overkill to test the side-effects, so... I'm not + # testing them. Yes, I know its a bad idea, but if someone has an easier + # way to write that, do send an MR. + resp = await test_cli.patch( + f'/api/v6/admin/guilds/{guild_id}', + headers={ + 'Authorization': token + }, json={ + 'unavailable': True + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['id'] == guild_id + assert rjson['unavailable'] + + rjson = await _fetch_guild(test_cli, guild_id, token=token) + assert rjson['unavailable'] + finally: + await delete_guild(int(guild_id), app_=test_cli.app) + + +@pytest.mark.asyncio +async def test_guild_delete(test_cli): + """Test the update of a guild via the Admin API.""" + token = await login('admin', test_cli) + rjson = await _create_guild(test_cli, token=token) + guild_id = rjson['id'] + + try: + resp = await test_cli.delete( + f'/api/v6/admin/guilds/{guild_id}', + headers={ + 'Authorization': token + }) + + assert resp.status_code == 204 + + resp = await _fetch_guild(test_cli, guild_id, token=token, + ret_early=True) + + assert resp.status_code == 404 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['error'] + assert rjson['code'] == GuildNotFound.error_code + finally: + await delete_guild(int(guild_id), app_=test_cli.app) diff --git a/tests/test_admin_api/test_users.py b/tests/test_admin_api/test_users.py index 3ad51f7..832fb9b 100644 --- a/tests/test_admin_api/test_users.py +++ b/tests/test_admin_api/test_users.py @@ -22,10 +22,12 @@ import secrets import pytest from tests.common import login +from tests.credentials import CREDS +from litecord.enums import UserFlags + async def _search(test_cli, *, username='', discrim='', token=None): - if token is None: - token = await login('admin', test_cli) + token = token or await login('admin', test_cli) query_string = { 'username': username, @@ -40,8 +42,7 @@ async def _search(test_cli, *, username='', discrim='', token=None): @pytest.mark.asyncio async def test_list_users(test_cli): """Try to list as many users as possible.""" - # NOTE: replace here if admin username changes - resp = await _search(test_cli, username='big_girl') + resp = await _search(test_cli, username=CREDS['admin']['username']) assert resp.status_code == 200 rjson = await resp.json @@ -49,11 +50,8 @@ async def test_list_users(test_cli): assert rjson -@pytest.mark.asyncio -async def test_create_delete(test_cli): - """Create a user. Then delete them.""" - token = await login('admin', test_cli) - +async def _setup_user(test_cli, *, token=None) -> dict: + token = token or await login('admin', test_cli) genned = secrets.token_hex(7) resp = await test_cli.post('/api/v6/admin/users', headers={ @@ -69,23 +67,73 @@ async def test_create_delete(test_cli): assert isinstance(rjson, dict) assert rjson['username'] == genned - genned_uid = rjson['id'] + return rjson - # check if side-effects went through with a search - resp = await _search(test_cli, username=genned, token=token) - assert resp.status_code == 200 - rjson = await resp.json - assert isinstance(rjson, list) - assert rjson[0]['id'] == genned_uid +async def _del_user(test_cli, user_id, *, token=None): + """Delete a user.""" + token = token or await login('admin', test_cli) - # delete - resp = await test_cli.delete(f'/api/v6/admin/users/{genned_uid}', headers={ + resp = await test_cli.delete(f'/api/v6/admin/users/{user_id}', headers={ 'Authorization': token }) assert resp.status_code == 200 rjson = await resp.json assert isinstance(rjson, dict) - assert rjson['new']['id'] == genned_uid + assert rjson['new']['id'] == user_id assert rjson['old']['id'] == rjson['new']['id'] + + # TODO: remove from database at this point? it'll just keep being + # filled up every time we run a test.. + + +@pytest.mark.asyncio +async def test_create_delete(test_cli): + """Create a user. Then delete them.""" + token = await login('admin', test_cli) + + rjson = await _setup_user(test_cli, token=token) + + genned = rjson['username'] + genned_uid = rjson['id'] + + try: + # check if side-effects went through with a search + resp = await _search(test_cli, username=genned, token=token) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, list) + assert rjson[0]['id'] == genned_uid + finally: + await _del_user(test_cli, genned_uid, token=token) + + +@pytest.mark.asyncio +async def test_user_update(test_cli): + """Test user update.""" + token = await login('admin', test_cli) + rjson = await _setup_user(test_cli, token=token) + + user_id = rjson['id'] + + # test update + + try: + # set them as partner flag + resp = await test_cli.patch(f'/api/v6/admin/users/{user_id}', headers={ + 'Authorization': token + }, json={ + 'flags': UserFlags.partner, + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert rjson['id'] == user_id + assert rjson['flags'] == UserFlags.partner + + # TODO: maybe we can check for side effects by fetching the + # user manually too... + finally: + await _del_user(test_cli, user_id, token=token)