Merge branch 'admin-api-more-endpoints' into 'master'

Admin API improvements

Closes #44

See merge request litecord/litecord!33
This commit is contained in:
Luna 2019-04-22 07:24:02 +00:00
commit 5dbf8ac8fd
12 changed files with 403 additions and 40 deletions

View File

@ -48,6 +48,26 @@ Output:
| old | user object | old user object pre-delete |
| new | user object | new user object post-delete |
### PATCH `/users/<user_id>`
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/<guild_id>`
Returns a partial guild object.
Returns a partial guild object. Gives a 404 when the guild is not found.
### PATCH `/guilds/<guild_id>`
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/<guild_id>`
Delete a single guild. Returns 204 on success.
## Guild features

View File

@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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}
}

View File

@ -17,9 +17,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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('/<int:guild_id>', 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('/<int:guild_id>', 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

View File

@ -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('/<int:user_id>', 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)

View File

@ -350,21 +350,17 @@ async def _update_guild(guild_id):
return jsonify(guild)
@bp.route('/<int:guild_id>', methods=['DELETE'])
# this endpoint is not documented, but used by the official client.
@bp.route('/<int:guild_id>/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('/<int:guild_id>', methods=['DELETE'])
# this endpoint is not documented, but used by the official client.
@bp.route('/<int:guild_id>/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

View File

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

View File

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

View File

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

View File

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

2
run.py
View File

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

View File

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

View File

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