diff --git a/docs/admin_api.md b/docs/admin_api.md index 4dc8863..d19658f 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -1,6 +1,86 @@ # Litecord Admin API -the base path is `/api/v6/admin`. +Litecord's Admin API uses the same authentication methods as Discord, +it's the same `Authorization` header, and the same token. + +Only users who have the staff flag set can use the Admin API. Instance +owners can use the `./manage.py make_staff` manage task to set someone +as a staff user, granting them access over the administration functions. + +The base path is `/api/v6/admin`. + +## User management + +### `PUT /users` + +Create a user. +Returns a user object. + +| field | type | description | +| --: | :-- | :-- | +| username | string | username | +| email | email | the email of the new user | +| password | str | password for the new user | + +### `GET /users` + +Search users. Input is query arguments with the search parameters. +Returns a list of user objects. + +| field | type | description | +| --: | :-- | :-- | +| username | string | username | +| discriminator | string | discriminator | +| page | Optional[integer] | page, default 0 | +| per\_page | Optional[integer] | users per page, default 20, max 50 | + +### `DELETE /users/` + +Delete a single user. Does not *actually* remove the user from the users row, +it changes the username to `Deleted User `, etc. + +Also disconnects all of the users' devices from the gateway. + +Output: + +| field | type | description | +| --: | :-- | :-- | +| old | user object | old user object pre-delete | +| new | user object | new user object post-delete | + + +## Instance invites + +Instance invites are used for instances that do not have open +registrations but want to let some people in regardless. Users +go to the `/invite_register.html` page in the instance and put +their data in. + +### Instance Invite object + +| field | type | description | +| --: | :-- | :-- | +| code | string | instance invite code | +| created\_at | ISO8907 timestamp | when the invite was created | +| max\_uses | integer | maximum amount of uses | +| uses | integer | how many times has the invite been used | + +### `GET /instance/invites` + +Get a list of instance invites. + +### `PUT /instance/invites` + +Create an instance invite. Receives only the `max_uses` +field from the instance invites object. Returns a full +instance invite object. + +### `DELETE /instance/invites/` + +Delete an invite. Does not have any input, only the instance invite's `code` +as the `` parameter in the URL. + +Returns empty body 204 on success, 404 on invite not found. ## Voice diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index 18d0e50..d4dba63 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -40,3 +40,13 @@ FEATURES = { 'schema': {'coerce': lambda x: Feature(x)} } } + +USER_CREATE = { + 'username': {'type': 'username', 'required': True}, + 'email': {'type': 'email', 'required': True}, + 'password': {'type': 'string', 'minlength': 5, 'required': True}, +} + +INSTANCE_INVITE = { + 'max_uses': {'type': 'integer', 'required': True} +} diff --git a/litecord/blueprints/admin_api/__init__.py b/litecord/blueprints/admin_api/__init__.py index 32710ce..d27cc52 100644 --- a/litecord/blueprints/admin_api/__init__.py +++ b/litecord/blueprints/admin_api/__init__.py @@ -20,5 +20,7 @@ along with this program. If not, see . from .voice import bp as voice from .features import bp as features from .guilds import bp as guilds +from .users import bp as users +from .instance_invites import bp as instance_invites -__all__ = ['voice', 'features', 'guilds'] +__all__ = ['voice', 'features', 'guilds', 'users', 'instance_invites'] diff --git a/litecord/blueprints/admin_api/instance_invites.py b/litecord/blueprints/admin_api/instance_invites.py new file mode 100644 index 0000000..dbd6c78 --- /dev/null +++ b/litecord/blueprints/admin_api/instance_invites.py @@ -0,0 +1,109 @@ +""" + +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 string +from random import choice + +from quart import Blueprint, jsonify, current_app as app, request + +from litecord.auth import admin_check +from litecord.types import timestamp_ +from litecord.schemas import validate +from litecord.admin_schemas import INSTANCE_INVITE + +bp = Blueprint('instance_invites', __name__) +ALPHABET = string.ascii_lowercase + string.ascii_uppercase + string.digits + + +async def _gen_inv() -> str: + """Generate an invite code""" + return ''.join(choice(ALPHABET) for _ in range(6)) + + +async def gen_inv(ctx) -> str: + """Generate an invite.""" + for _ in range(10): + possible_inv = await _gen_inv() + + created_at = await ctx.db.fetchval(""" + SELECT created_at + FROM instance_invites + WHERE code = $1 + """, possible_inv) + + if created_at is None: + return possible_inv + + return None + + +@bp.route('', methods=['GET']) +async def _all_instance_invites(): + await admin_check() + + rows = await app.db.fetch(""" + SELECT code, created_at, uses, max_uses + FROM instance_invites + """) + + rows = [dict(row) for row in rows] + + for row in rows: + row['created_at'] = timestamp_(row['created_at']) + + return jsonify(rows) + + +@bp.route('', methods=['PUT']) +async def _create_invite(): + await admin_check() + + code = await gen_inv(app) + if code is None: + return 'failed to make invite', 500 + + j = validate(await request.get_json(), INSTANCE_INVITE) + + await app.db.execute(""" + INSERT INTO instance_invites (code, max_uses) + VALUES ($1, $2) + """, code, j['max_uses']) + + inv = dict(await app.db.fetchrow(""" + SELECT code, created_at, uses, max_uses + FROM instance_invites + WHERE code = $1 + """, code)) + + return jsonify(dict(inv)) + + +@bp.route('/', methods=['DELETE']) +async def _del_invite(invite: str): + await admin_check() + + res = await app.db.execute(""" + DELETE FROM instance_invites + WHERE code = $1 + """, invite) + + if res.lower() == 'delete 0': + return 'invite not found', 404 + + return '', 204 diff --git a/litecord/blueprints/admin_api/users.py b/litecord/blueprints/admin_api/users.py new file mode 100644 index 0000000..6f309d6 --- /dev/null +++ b/litecord/blueprints/admin_api/users.py @@ -0,0 +1,118 @@ +""" + +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 . + +""" + +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.utils import async_map +from litecord.blueprints.users import delete_user, user_disconnect + +bp = Blueprint('users_admin', __name__) + +@bp.route('', methods=['POST']) +async def _create_user(): + await admin_check() + + j = validate(await request.get_json(), USER_CREATE) + + user_id, _ = await create_user(j['username'], j['email'], j['password']) + + return jsonify( + await app.storage.get_user(user_id) + ) + + +def args_try(args: dict, typ, field: str, default): + """Try to fetch a value from the request arguments, + given a type.""" + try: + return typ(args.get(field, default)) + except (TypeError, ValueError): + raise BadRequest(f'invalid {field} value') + + +@bp.route('', methods=['GET']) +async def _search_users(): + await admin_check() + + args = request.args + + username, discrim = args.get('username'), args.get('discriminator') + + per_page = args_try(args, int, 'per_page', 20) + page = args_try(args, int, 'page', 0) + + if page < 0: + raise BadRequest('invalid page number') + + if per_page > 50: + raise BadRequest('invalid per page number') + + # any of those must be available. + if not any((username, discrim)): + raise BadRequest('must insert username or discrim') + + wheres, args = [], [] + + if username: + wheres.append("username LIKE '%' || $2 || '%'") + args.append(username) + + if discrim: + wheres.append(f'discriminator = ${len(args) + 2}') + args.append(discrim) + + where_tot = 'WHERE ' if args else '' + where_tot += ' AND '.join(wheres) + + rows = await app.db.fetch(f""" + SELECT id + FROM users + {where_tot} + ORDER BY id ASC + LIMIT {per_page} + OFFSET ($1 * {per_page}) + """, page, *args) + + rows = [r['id'] for r in rows] + + return jsonify( + await async_map(app.storage.get_user, rows) + ) + + +@bp.route('/', methods=['DELETE']) +async def _delete_single_user(user_id: int): + await admin_check() + + old_user = await app.storage.get_user(user_id) + + await delete_user(user_id) + await user_disconnect(user_id) + + new_user = await app.storage.get_user(user_id) + + return jsonify({ + 'old': old_user, + 'new': new_user + }) diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index 1145b8d..3789eda 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -545,7 +545,8 @@ async def delete_user(user_id, *, db=None): await _del_from_table(db, 'channel_overwrites', user_id) -async def _force_disconnect(user_id): +async def user_disconnect(user_id): + """Disconnects the given user's devices.""" # after removing the user from all tables, we need to force # all known user states to reconnect, causing the user to not # be online anymore. @@ -597,6 +598,6 @@ async def delete_account(): raise Unauthorized('password does not match') await delete_user(user_id) - await _force_disconnect(user_id) + await user_disconnect(user_id) return '', 204 diff --git a/manage/cmd/migration/__init__.py b/manage/cmd/migration/__init__.py index e2363a2..d685676 100644 --- a/manage/cmd/migration/__init__.py +++ b/manage/cmd/migration/__init__.py @@ -18,3 +18,5 @@ along with this program. If not, see . """ from .command import setup as migration + +__all__ = ['migration'] diff --git a/manage/cmd/users.py b/manage/cmd/users.py index 1516673..4d37757 100644 --- a/manage/cmd/users.py +++ b/manage/cmd/users.py @@ -17,9 +17,6 @@ along with this program. If not, see . """ -import base64 -import itsdangerous -import bcrypt from litecord.blueprints.auth import create_user, make_token from litecord.blueprints.users import delete_user from litecord.enums import UserFlags diff --git a/run.py b/run.py index 15ce2b4..f8bf1c7 100644 --- a/run.py +++ b/run.py @@ -58,7 +58,7 @@ from litecord.blueprints.user.billing_job import payment_job from litecord.blueprints.admin_api import ( voice as voice_admin, features as features_admin, - guilds as guilds_admin + guilds as guilds_admin, users as users_admin, instance_invites ) from litecord.blueprints.admin_api.voice import guild_region_check @@ -146,7 +146,9 @@ def set_blueprints(app_): voice_admin: '/admin/voice', features_admin: '/admin/guilds', - guilds_admin: '/admin/guilds' + guilds_admin: '/admin/guilds', + users_admin: '/admin/users', + instance_invites: '/admin/instance/invites' } for bp, suffix in bps.items(): diff --git a/tests/conftest.py b/tests/conftest.py index 66c5e6f..5917a75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,11 @@ def _test_app(unused_tcp_port, event_loop): # make sure we're calling the before_serving hooks event_loop.run_until_complete(main_app.startup()) - return main_app + # https://docs.pytest.org/en/latest/fixture.html#fixture-finalization-executing-teardown-code + yield main_app + + # properly teardown + event_loop.run_until_complete(main_app.shutdown()) @pytest.fixture(name='test_cli') diff --git a/tests/test_admin_api/test_instance_invites.py b/tests/test_admin_api/test_instance_invites.py new file mode 100644 index 0000000..aef9fb8 --- /dev/null +++ b/tests/test_admin_api/test_instance_invites.py @@ -0,0 +1,87 @@ +""" + +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 pytest + +from tests.common import login + +async def _get_invs(test_cli, token=None): + if token is None: + token = await login('admin', test_cli) + + resp = await test_cli.get('/api/v6/admin/instance/invites', headers={ + 'Authorization': token + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, list) + return rjson + + +@pytest.mark.asyncio +async def test_get_invites(test_cli): + """Test the listing of instance invites.""" + await _get_invs(test_cli) + + +@pytest.mark.asyncio +async def test_inv_delete_invalid(test_cli): + """Test errors happen when trying to delete a + non-existing instance invite.""" + token = await login('admin', test_cli) + resp = await test_cli.delete( + '/api/v6/admin/instance/invites/aaaaaaaaaa', + headers={ + 'Authorization': token + } + ) + + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_invite(test_cli): + """Test the creation of an instance invite, then listing it, + then deleting it.""" + token = await login('admin', test_cli) + resp = await test_cli.put('/api/v6/admin/instance/invites', headers={ + 'Authorization': token + }, json={ + 'max_uses': 1 + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + code = rjson['code'] + + # assert that the invite is in the list + invites = await _get_invs(test_cli, token) + assert any(inv['code'] == code for inv in invites) + + # delete it, and assert it worked + resp = await test_cli.delete( + f'/api/v6/admin/instance/invites/{code}', + headers={ + 'Authorization': token + } + ) + + assert resp.status_code == 204 diff --git a/tests/test_admin_api/test_users.py b/tests/test_admin_api/test_users.py new file mode 100644 index 0000000..3ad51f7 --- /dev/null +++ b/tests/test_admin_api/test_users.py @@ -0,0 +1,91 @@ +""" + +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 + +async def _search(test_cli, *, username='', discrim='', token=None): + if token is None: + token = await login('admin', test_cli) + + query_string = { + 'username': username, + 'discriminator': discrim + } + + return await test_cli.get('/api/v6/admin/users', headers={ + 'Authorization': token + }, query_string=query_string) + + +@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') + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, list) + assert rjson + + +@pytest.mark.asyncio +async def test_create_delete(test_cli): + """Create a user. Then delete them.""" + token = await login('admin', test_cli) + + genned = secrets.token_hex(7) + + resp = await test_cli.post('/api/v6/admin/users', headers={ + 'Authorization': token + }, json={ + 'username': genned, + 'email': f'{genned}@{genned}.com', + 'password': genned, + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['username'] == genned + + genned_uid = rjson['id'] + + # 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 + + # delete + resp = await test_cli.delete(f'/api/v6/admin/users/{genned_uid}', headers={ + 'Authorization': token + }) + + assert resp.status_code == 200 + rjson = await resp.json + assert isinstance(rjson, dict) + assert rjson['new']['id'] == genned_uid + assert rjson['old']['id'] == rjson['new']['id']