Merge branch 'admin-api-conversion' into 'master'

Admin api conversion

Closes #38

See merge request litecord/litecord!26
This commit is contained in:
Luna 2019-03-17 02:33:53 +00:00
commit 67acf7e22a
12 changed files with 513 additions and 10 deletions

View File

@ -1,6 +1,86 @@
# Litecord Admin API # 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/<user_id>`
Delete a single user. Does not *actually* remove the user from the users row,
it changes the username to `Deleted User <random hex>`, 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/<invite>`
Delete an invite. Does not have any input, only the instance invite's `code`
as the `<invite>` parameter in the URL.
Returns empty body 204 on success, 404 on invite not found.
## Voice ## Voice

View File

@ -40,3 +40,13 @@ FEATURES = {
'schema': {'coerce': lambda x: Feature(x)} '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}
}

View File

@ -20,5 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
from .voice import bp as voice from .voice import bp as voice
from .features import bp as features from .features import bp as features
from .guilds import bp as guilds 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']

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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('/<invite>', 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

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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('/<int:user_id>', 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
})

View File

@ -545,7 +545,8 @@ async def delete_user(user_id, *, db=None):
await _del_from_table(db, 'channel_overwrites', user_id) 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 # after removing the user from all tables, we need to force
# all known user states to reconnect, causing the user to not # all known user states to reconnect, causing the user to not
# be online anymore. # be online anymore.
@ -597,6 +598,6 @@ async def delete_account():
raise Unauthorized('password does not match') raise Unauthorized('password does not match')
await delete_user(user_id) await delete_user(user_id)
await _force_disconnect(user_id) await user_disconnect(user_id)
return '', 204 return '', 204

View File

@ -18,3 +18,5 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from .command import setup as migration from .command import setup as migration
__all__ = ['migration']

View File

@ -17,9 +17,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
import base64
import itsdangerous
import bcrypt
from litecord.blueprints.auth import create_user, make_token from litecord.blueprints.auth import create_user, make_token
from litecord.blueprints.users import delete_user from litecord.blueprints.users import delete_user
from litecord.enums import UserFlags from litecord.enums import UserFlags

6
run.py
View File

@ -58,7 +58,7 @@ from litecord.blueprints.user.billing_job import payment_job
from litecord.blueprints.admin_api import ( from litecord.blueprints.admin_api import (
voice as voice_admin, features as features_admin, 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 from litecord.blueprints.admin_api.voice import guild_region_check
@ -146,7 +146,9 @@ def set_blueprints(app_):
voice_admin: '/admin/voice', voice_admin: '/admin/voice',
features_admin: '/admin/guilds', 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(): for bp, suffix in bps.items():

View File

@ -45,7 +45,11 @@ def _test_app(unused_tcp_port, event_loop):
# make sure we're calling the before_serving hooks # make sure we're calling the before_serving hooks
event_loop.run_until_complete(main_app.startup()) 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') @pytest.fixture(name='test_cli')

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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']