diff --git a/docs/admin_api.md b/docs/admin_api.md index 3c4649c..4dc8863 100644 --- a/docs/admin_api.md +++ b/docs/admin_api.md @@ -2,7 +2,9 @@ the base path is `/api/v6/admin`. -## GET `/voice/regions/` +## Voice + +### GET `/voice/regions/` Return a list of voice server objects for the region. @@ -13,7 +15,7 @@ Returns empty list if the region does not exist. | hostname | string | the hostname of the voice server | | last\_health | float | the health of the voice server | -## PUT `/voice/regions` +### PUT `/voice/regions` Create a voice region. @@ -27,7 +29,7 @@ Receives JSON body as input, returns a list of voice region objects as output. | deprecated | Optional[bool] | if voice region is deprecated, default false | | custom | Optional[bool] | if voice region is custom-only, default false | -## PUT `/voice/regions//server` +### PUT `/voice/regions//server` Create a voice server for a region. @@ -37,9 +39,49 @@ Returns empty body with 204 status code on success. | --: | :-- | :-- | | hostname | string | the hostname of the voice server | -## PUT `/voice/regions//deprecate` +### PUT `/voice/regions//deprecate` Mark a voice region as deprecated. Disables any voice actions on guilds that are using the voice region. Returns empty body with 204 status code on success. + +## Guilds + +### GET `/guilds/` + +Returns a partial guild object. + +## Guild features + +The currently supported features are: + - `INVITE_SPLASH`, allows custom images to be put for invites. + - `VIP_REGIONS`, allows a guild to use voice regions marked as VIP. + - `VANITY_URL`, allows a custom invite URL to be used. + - `MORE_EMOJI`, bumps the emoji limit from 50 to 200 (applies to static and + animated emoji). + - `VERIFIED`, adds a verified badge and a guild banner being shown on the + top of the channel list. + +Features that are not planned to be implemented: + - `COMMERCE` + - `NEWS` + +### PATCH `/guilds//features` + +Patch the entire features list. Returns the new feature list following the same +structure as the input. + +| field | type | description | +| --: | :-- | :-- | +| features | List[string] | new list of features | + +### PUT `/guilds//features` + +Insert features. Receives and returns the same structure as +PATCH `/guilds//features`. + +### DELETE `/guilds//features` + +Remove features. Receives and returns the same structure as +PATCH `/guilds//features`. diff --git a/litecord/admin_schemas.py b/litecord/admin_schemas.py index 3896dc9..18d0e50 100644 --- a/litecord/admin_schemas.py +++ b/litecord/admin_schemas.py @@ -17,6 +17,8 @@ along with this program. If not, see . """ +from litecord.enums import Feature + VOICE_SERVER = { 'hostname': {'type': 'string', 'maxlength': 255, 'required': True} } @@ -29,3 +31,12 @@ VOICE_REGION = { 'deprecated': {'type': 'boolean', 'default': False}, 'custom': {'type': 'boolean', 'default': False}, } + +FEATURES = { + 'features': { + 'type': 'list', 'required': True, + + # using Feature doesn't seem to work with a "not callable" error. + 'schema': {'coerce': lambda x: Feature(x)} + } +} diff --git a/litecord/blueprints/admin_api/__init__.py b/litecord/blueprints/admin_api/__init__.py index d209cf9..32710ce 100644 --- a/litecord/blueprints/admin_api/__init__.py +++ b/litecord/blueprints/admin_api/__init__.py @@ -18,5 +18,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 -__all__ = ['voice'] +__all__ = ['voice', 'features', 'guilds'] diff --git a/litecord/blueprints/admin_api/features.py b/litecord/blueprints/admin_api/features.py new file mode 100644 index 0000000..69b338f --- /dev/null +++ b/litecord/blueprints/admin_api/features.py @@ -0,0 +1,111 @@ +""" + +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 typing import List + +from quart import Blueprint, current_app as app, jsonify, request + +from litecord.auth import admin_check +from litecord.errors import BadRequest +from litecord.schemas import validate +from litecord.admin_schemas import FEATURES +from litecord.blueprints.guilds import vanity_invite + +bp = Blueprint('features_admin', __name__) + + +async def _features_from_req() -> List[str]: + j = validate(await request.get_json(), FEATURES) + return [feature.value for feature in j['features']] + + +async def _features(guild_id: int): + return jsonify({ + 'features': await app.storage.guild_features(guild_id) + }) + + +async def _update_features(guild_id: int, features: list): + if 'VANITY_URL' not in features: + existing_inv = await vanity_invite(guild_id) + + await app.db.execute(""" + DELETE FROM vanity_invites + WHERE guild_id = $1 + """, guild_id) + + await app.db.execute(""" + DELETE FROM invites + WHERE code = $1 + """, existing_inv) + + await app.db.execute(""" + UPDATE guilds + SET features = $1 + WHERE id = $2 + """, features, guild_id) + + guild = await app.storage.get_guild_full(guild_id) + await app.dispatcher.dispatch('guild', guild_id, 'GUILD_UPDATE', guild) + + +@bp.route('//features', methods=['PATCH']) +async def replace_features(guild_id: int): + """Replace the feature list in a guild""" + await admin_check() + features = await _features_from_req() + + # yes, we need to pass it to a set and then to a list before + # doing anything, since the api client might just + # shove 200 repeated features to us. + await _update_features(guild_id, list(set(features))) + return await _features(guild_id) + + +@bp.route('//features', methods=['PUT']) +async def insert_features(guild_id: int): + """Insert a feature on a guild.""" + await admin_check() + to_add = await _features_from_req() + + features = await app.storage.guild_features(guild_id) + features = set(features) + + # i'm assuming set.add is mostly safe + for feature in to_add: + features.add(feature) + + await _update_features(guild_id, list(features)) + return await _features(guild_id) + + +@bp.route('//features', methods=['DELETE']) +async def remove_features(guild_id: int): + """Remove a feature from a guild""" + await admin_check() + to_remove = await _features_from_req() + features = await app.storage.guild_features(guild_id) + + for feature in to_remove: + try: + features.remove(feature) + except ValueError: + raise BadRequest('Trying to remove already removed feature.') + + await _update_features(guild_id, features) + return await _features(guild_id) diff --git a/litecord/blueprints/admin_api/guilds.py b/litecord/blueprints/admin_api/guilds.py new file mode 100644 index 0000000..d779c02 --- /dev/null +++ b/litecord/blueprints/admin_api/guilds.py @@ -0,0 +1,33 @@ +""" + +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 + +from litecord.auth import admin_check + +bp = Blueprint('guilds_admin', __name__) + +@bp.route('/', methods=['GET']) +async def get_guild(guild_id: int): + """Get a basic guild payload.""" + await admin_check() + + return jsonify( + await app.storage.get_guild(guild_id) + ) diff --git a/litecord/blueprints/auth.py b/litecord/blueprints/auth.py index e779e27..e6d4124 100644 --- a/litecord/blueprints/auth.py +++ b/litecord/blueprints/auth.py @@ -23,6 +23,7 @@ import secrets import itsdangerous import bcrypt from quart import Blueprint, jsonify, request, current_app as app +from logbook import Logger from litecord.auth import token_check, create_user from litecord.schemas import validate, REGISTER, REGISTER_WITH_INVITE @@ -30,7 +31,7 @@ from litecord.errors import BadRequest from litecord.snowflake import get_snowflake from .invites import use_invite - +log = Logger(__name__) bp = Blueprint('auth', __name__) @@ -64,10 +65,17 @@ async def register(): j = await request.get_json() if not 'password' in j: - j['password'] = 'default_password' # we need some password to make a token + # we need a password to generate a token. + # passwords are optional, so + j['password'] = 'default_password' j = validate(j, REGISTER) - email, password, username, invite = j['email'] if 'email' in j else None, j['password'], j['username'], j['invite'] + + # they're optional + email = j.get('email') + invite = j.get('invite') + + username, password = j['username'], j['password'] new_id, pwd_hash = await create_user( username, email, password, app.db @@ -76,9 +84,9 @@ async def register(): if invite: try: await use_invite(new_id, invite) - except Exception as e: - print(e) - pass # do nothing + except Exception: + log.exception('failed to use invite for register {} {!r}', + new_id, invite) return jsonify({ 'token': make_token(new_id, pwd_hash) diff --git a/litecord/blueprints/guild/emoji.py b/litecord/blueprints/guild/emoji.py index 925adac..cc48dfe 100644 --- a/litecord/blueprints/guild/emoji.py +++ b/litecord/blueprints/guild/emoji.py @@ -24,6 +24,8 @@ from litecord.blueprints.checks import guild_check, guild_perm_check from litecord.schemas import validate, NEW_EMOJI, PATCH_EMOJI from litecord.snowflake import get_snowflake from litecord.types import KILOBYTES +from litecord.images import parse_data_uri +from litecord.errors import BadRequest bp = Blueprint('guild.emoji', __name__) @@ -54,6 +56,24 @@ async def _get_guild_emoji_one(guild_id, emoji_id): ) +async def _guild_emoji_size_check(guild_id: int, mime: str): + limit = 50 + if await app.storage.has_feature(guild_id, 'MORE_EMOJI'): + limit = 200 + + # NOTE: I'm assuming you can have 200 animated emojis. + select_animated = mime == 'image/gif' + + total_emoji = await app.db.fetchval(""" + SELECT COUNT(*) FROM guild_emoji + WHERE guild_id = $1 AND animated = $2 + """, guild_id, select_animated) + + if total_emoji >= limit: + # TODO: really return a BadRequest? needs more looking. + raise BadRequest(f'too many emoji ({limit})') + + @bp.route('//emojis', methods=['POST']) async def _put_emoji(guild_id): user_id = await token_check() @@ -63,6 +83,11 @@ async def _put_emoji(guild_id): j = validate(await request.get_json(), NEW_EMOJI) + # we have to parse it before passing on so that we know which + # size to check. + mime, _ = parse_data_uri(j['image']) + await _guild_emoji_size_check(guild_id, mime) + emoji_id = get_snowflake() icon = await app.icons.put( @@ -75,6 +100,8 @@ async def _put_emoji(guild_id): if not icon: return '', 400 + # TODO: better way to detect animated emoji rather than just gifs, + # maybe a list perhaps? await app.db.execute( """ INSERT INTO guild_emoji @@ -85,7 +112,8 @@ async def _put_emoji(guild_id): emoji_id, guild_id, user_id, j['name'], icon.icon_hash, - icon.mime == 'image/gif') + icon.mime == 'image/gif' + ) await _dispatch_emojis(guild_id) diff --git a/litecord/blueprints/guilds.py b/litecord/blueprints/guilds.py index bed7881..c4675b8 100644 --- a/litecord/blueprints/guilds.py +++ b/litecord/blueprints/guilds.py @@ -17,6 +17,8 @@ along with this program. If not, see . """ +from typing import Optional + from quart import Blueprint, request, current_app as app, jsonify from litecord.blueprints.guild.channels import create_guild_channel @@ -28,10 +30,12 @@ from ..auth import token_check from ..snowflake import get_snowflake from ..enums import ChannelType from ..schemas import ( - validate, GUILD_CREATE, GUILD_UPDATE, SEARCH_CHANNEL + validate, GUILD_CREATE, GUILD_UPDATE, SEARCH_CHANNEL, + VANITY_URL_PATCH ) from .channels import channel_ack from .checks import guild_check, guild_owner_check, guild_perm_check +from litecord.utils import to_update from litecord.errors import BadRequest @@ -105,17 +109,39 @@ async def guild_create_channels_prep(guild_id: int, channels: list): await create_guild_channel(guild_id, channel_id, ctype) -async def put_guild_icon(guild_id: int, icon: str): - """Insert a guild icon on the icon database.""" +def sanitize_icon(icon: Optional[str]) -> Optional[str]: + """Return sanitized version of the given icon. + + Defaults to a jpeg icon when the header isn't given. + """ if icon and icon.startswith('data'): - encoded = icon - else: - encoded = (f'data:image/jpeg;base64,{icon}' - if icon - else None) + return icon + + return (f'data:image/jpeg;base64,{icon}' + if icon + else None) + + +async def _general_guild_icon(scope: str, guild_id: int, + icon: str, **kwargs): + encoded = sanitize_icon(icon) + + icon_kwargs = { + 'always_icon': True + } + + if 'size' in kwargs: + icon_kwargs['size'] = kwargs['size'] return await app.icons.put( - 'guild', guild_id, encoded, size=(128, 128), always_icon=True) + scope, guild_id, encoded, + **icon_kwargs + ) + + +async def put_guild_icon(guild_id: int, icon: Optional[str]): + """Insert a guild icon on the icon database.""" + return await _general_guild_icon('guild', guild_id, icon, size=(128, 128)) @bp.route('', methods=['POST']) @@ -195,6 +221,39 @@ async def get_guild(guild_id): ) +async def _guild_update_icon(scope: str, guild_id: int, + icon: Optional[str], **kwargs): + """Update icon.""" + new_icon = await app.icons.update( + scope, guild_id, icon, always_icon=True, **kwargs + ) + + table = { + 'guild': 'icon', + }.get(scope, scope) + + await app.db.execute(f""" + UPDATE guilds + SET {table} = $1 + WHERE id = $2 + """, new_icon.icon_hash, guild_id) + + +async def _guild_update_region(guild_id, region): + is_vip = region.vip + can_vip = await app.storage.has_feature(guild_id, 'VIP_REGIONS') + + if is_vip and not can_vip: + raise BadRequest('can not assign guild to vip-only region') + + await app.db.execute(""" + UPDATE guilds + SET region = $1 + WHERE id = $2 + """, region.id, guild_id) + + + @bp.route('/', methods=['PATCH']) async def _update_guild(guild_id): user_id = await token_check() @@ -220,26 +279,32 @@ async def _update_guild(guild_id): """, j['name'], guild_id) if 'region' in j: - await app.db.execute(""" - UPDATE guilds - SET region = $1 - WHERE id = $2 - """, j['region'], guild_id) + region = app.voice.lvsp.region(j['region']) + + if region is not None: + await _guild_update_region(guild_id, region) if 'icon' in j: - # delete old - new_icon = await app.icons.update( - 'guild', guild_id, j['icon'], always_icon=True - ) + await _guild_update_icon( + 'guild', guild_id, j['icon'], size=(128, 128)) - await app.db.execute(""" - UPDATE guilds - SET icon = $1 - WHERE id = $2 - """, new_icon.icon_hash, guild_id) + # small guild to work with to_update() + guild = await app.storage.get_guild(guild_id) + + if to_update(j, guild, 'splash'): + if not await app.storage.has_feature(guild_id, 'INVITE_SPLASH'): + raise BadRequest('guild does not have INVITE_SPLASH feature') + + await _guild_update_icon('splash', guild_id, j['splash']) + + if to_update(j, guild, 'banner'): + if not await app.storage.has_feature(guild_id, 'VERIFIED'): + raise BadRequest('guild is not verified') + + await _guild_update_icon('banner', guild_id, j['banner']) fields = ['verification_level', 'default_message_notifications', - 'explicit_content_filter', 'afk_timeout'] + 'explicit_content_filter', 'afk_timeout', 'description'] for field in [f for f in fields if f in j]: await app.db.execute(f""" @@ -371,3 +436,95 @@ async def ack_guild(guild_id): await channel_ack(user_id, guild_id, chan_id) return '', 204 + + +async def vanity_invite(guild_id: int) -> Optional[str]: + """Get the vanity invite for a guild.""" + return await app.db.fetchval(""" + SELECT code FROM vanity_invites + WHERE guild_id = $1 + """, guild_id) + + +@bp.route('//vanity-url', methods=['GET']) +async def get_vanity_url(guild_id: int): + """Get the vanity url of a guild.""" + user_id = await token_check() + await guild_perm_check(user_id, guild_id, 'manage_guild') + + inv_code = await vanity_invite(guild_id) + + if inv_code is None: + return jsonify({'code': None}) + + return jsonify( + await app.storage.get_invite(inv_code) + ) + + +@bp.route('//vanity-url', methods=['PATCH']) +async def change_vanity_url(guild_id: int): + """Get the vanity url of a guild.""" + user_id = await token_check() + + if not await app.storage.has_feature(guild_id, 'VANITY_URL'): + # TODO: is this the right error + raise BadRequest('guild has no vanity url support') + + await guild_perm_check(user_id, guild_id, 'manage_guild') + + j = validate(await request.get_json(), VANITY_URL_PATCH) + inv_code = j['code'] + + # store old vanity in a variable to delete it from + # invites table + old_vanity = await vanity_invite(guild_id) + + if old_vanity == inv_code: + raise BadRequest('can not change to same invite') + + # this is sad because we don't really use the things + # sql gives us, but i havent really found a way to put + # multiple ON CONFLICT clauses so we could UPDATE when + # guild_id_fkey fails but INSERT when code_fkey fails.. + inv = await app.storage.get_invite(inv_code) + if inv: + raise BadRequest('invite already exists') + + # TODO: this is bad, what if a guild has no channels? + # we should probably choose the first channel that has + # @everyone read messages + channels = await app.storage.get_channel_data(guild_id) + channel_id = int(channels[0]['id']) + + # delete the old invite, insert new one + await app.db.execute(""" + DELETE FROM invites + WHERE code = $1 + """, old_vanity) + + await app.db.execute( + """ + INSERT INTO invites + (code, guild_id, channel_id, inviter, max_uses, + max_age, temporary) + VALUES ($1, $2, $3, $4, $5, $6, $7) + """, + inv_code, guild_id, channel_id, user_id, + + # sane defaults for vanity urls. + 0, 0, False, + ) + + await app.db.execute(""" + INSERT INTO vanity_invites (guild_id, code) + VALUES ($1, $2) + ON CONFLICT ON CONSTRAINT vanity_invites_pkey DO + UPDATE + SET code = $2 + WHERE vanity_invites.guild_id = $1 + """, guild_id, inv_code) + + return jsonify( + await app.storage.get_invite(inv_code) + ) diff --git a/litecord/blueprints/icons.py b/litecord/blueprints/icons.py index 65b8de2..7434bad 100644 --- a/litecord/blueprints/icons.py +++ b/litecord/blueprints/icons.py @@ -18,7 +18,7 @@ along with this program. If not, see . """ from os.path import splitext -from quart import Blueprint, current_app as app, send_file, request +from quart import Blueprint, current_app as app, send_file bp = Blueprint('images', __name__) @@ -53,11 +53,6 @@ async def _get_guild_icon(guild_id: int, icon_file: str): return await send_icon('guild', guild_id, icon_hash, ext=ext) -@bp.route('/splashes//.', methods=['GET']) -async def _get_guild_splash(guild_id: int, splash_hash: str, ext: str): - pass - - @bp.route('/embed/avatars/.png') async def _get_default_user_avatar(discrim: int): pass @@ -65,11 +60,8 @@ async def _get_default_user_avatar(discrim: int): @bp.route('/avatars//') async def _get_user_avatar(user_id, avatar_file): - size_int = int(request.args.get('size', '1024')) - print('user request size', size_int) avatar_hash, ext = splitext_(avatar_file) - return await send_icon( - 'user', user_id, avatar_hash, ext=ext) + return await send_icon('user', user_id, avatar_hash, ext=ext) # @bp.route('/app-icons//.') @@ -78,6 +70,18 @@ async def get_app_icon(application_id, icon_hash, ext): @bp.route('/channel-icons//', methods=['GET']) -async def _get_gdm_icon(guild_id: int, icon_file: str): +async def _get_gdm_icon(channel_id: int, icon_file: str): icon_hash, ext = splitext_(icon_file) - return await send_icon('channel-icons', guild_id, icon_hash, ext=ext) + return await send_icon('channel-icons', channel_id, icon_hash, ext=ext) + + +@bp.route('/splashes//', methods=['GET']) +async def _get_guild_splash(guild_id: int, icon_file: str): + icon_hash, ext = splitext_(icon_file) + return await send_icon('splash', guild_id, icon_hash, ext=ext) + + +@bp.route('/banners//', methods=['GET']) +async def _get_guild_banner(guild_id: int, icon_file: str): + icon_hash, ext = splitext_(icon_file) + return await send_icon('banner', guild_id, icon_hash, ext=ext) diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index 261b0a7..1145b8d 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -36,6 +36,7 @@ from litecord.images import parse_data_uri from litecord.permissions import base_permissions from litecord.blueprints.auth import check_password +from litecord.utils import to_update bp = Blueprint('user', __name__) log = Logger(__name__) @@ -185,10 +186,6 @@ async def _try_discrim_patch(user_id, new_discrim: str): }) -def to_update(j: dict, user: dict, field: str): - return field in j and j[field] and j[field] != user[field] - - async def _check_pass(j, user): # Do not do password checks on unclaimed accounts if user['email'] is None: diff --git a/litecord/enums.py b/litecord/enums.py index 4107fd5..52cd742 100644 --- a/litecord/enums.py +++ b/litecord/enums.py @@ -18,12 +18,16 @@ along with this program. If not, see . """ import inspect +from typing import List, Any from enum import Enum, IntEnum class EasyEnum(Enum): + """Wrapper around the enum class for convenience.""" + @classmethod - def values(cls): + def values(cls) -> List[Any]: + """Return list of values for the given enum.""" return [v.value for v in cls.__members__.values()] @@ -197,12 +201,27 @@ class RelationshipType(EasyEnum): class MessageNotifications(EasyEnum): + """Message notifications""" ALL = 0 MENTIONS = 1 NOTHING = 2 class PremiumType: + """Premium (Nitro) type.""" TIER_1 = 1 TIER_2 = 2 NONE = None + + +class Feature(EasyEnum): + """Guild features.""" + invite_splash = 'INVITE_SPLASH' + vip = 'VIP_REGIONS' + vanity = 'VANITY_URL' + emoji = 'MORE_EMOJI' + verified = 'VERIFIED' + + # unknown + commerce = 'COMMERCE' + news = 'NEWS' diff --git a/litecord/gateway/websocket.py b/litecord/gateway/websocket.py index c2977b7..4cc22aa 100644 --- a/litecord/gateway/websocket.py +++ b/litecord/gateway/websocket.py @@ -74,7 +74,16 @@ def decode_json(data: str): def encode_etf(payload) -> str: - return earl.pack(payload) + # The thing with encoding ETF is that with json we have LitecordJSONEncoder + # which takes care of converting e.g datetime objects to their ISO + # representation. + + # so, to keep things working, i'll to a json pass on the payload, then send + # the decoded payload back to earl. + + sanitized = encode_json(payload) + sanitized = decode_json(sanitized) + return earl.pack(sanitized) def _etf_decode_dict(data): diff --git a/litecord/images.py b/litecord/images.py index 1db1ece..8db1e2c 100644 --- a/litecord/images.py +++ b/litecord/images.py @@ -46,6 +46,7 @@ EXTENSIONS = { MIMES = { 'jpg': 'image/jpeg', 'jpe': 'image/jpeg', + 'jpeg': 'image/jpeg', 'webp': 'image/webp', } @@ -171,15 +172,23 @@ def parse_data_uri(string) -> tuple: def _gen_update_sql(scope: str) -> str: + # match a scope to (table, field) field = { 'user': 'avatar', 'guild': 'icon', + 'splash': 'splash', + 'banner': 'banner', + 'channel-icons': 'icon', }[scope] table = { 'user': 'users', + 'guild': 'guilds', + 'splash': 'guilds', + 'banner': 'guilds', + 'channel-icons': 'group_dm_channels' }[scope] @@ -269,6 +278,8 @@ class IconManager: self.storage = app.storage async def _convert_ext(self, icon: Icon, target: str): + target = 'jpeg' if target == 'jpg' else target + target_mime = get_mime(target) log.info('converting from {} to {}', icon.mime, target_mime) @@ -279,6 +290,10 @@ class IconManager: image = Image.open(icon.as_path) target_fd = target_path.open('wb') + + if target == 'jpeg': + image = image.convert('RGB') + image.save(target_fd, format=target) target_fd.close() @@ -386,6 +401,9 @@ class IconManager: if scope == 'user' and mime == 'image/gif': icon_hash = f'a_{icon_hash}' + log.debug('PUT icon {!r} {!r} {!r} {!r}', + scope, key, icon_hash, mime) + await self.storage.db.execute(""" INSERT INTO icons (scope, key, hash, mime) VALUES ($1, $2, $3, $4) @@ -406,6 +424,9 @@ class IconManager: if not icon: return + log.debug('DEL {}', + icon) + # dereference await self.storage.db.execute(""" UPDATE users @@ -430,6 +451,18 @@ class IconManager: WHERE icon = $1 """, icon.icon_hash) + await self.storage.db.execute(""" + UPDATE guilds + SET splash = NULL + WHERE splash = $1 + """, icon.icon_hash) + + await self.storage.db.execute(""" + UPDATE guilds + SET banner = NULL + WHERE banner = $1 + """, icon.icon_hash) + await self.storage.db.execute(""" UPDATE group_dm_channels SET icon = NULL @@ -455,7 +488,11 @@ class IconManager: old_icon_hash = await self.storage.db.fetchval( _gen_update_sql(scope), key) + # converting key to str only here since from here onwards + # its operations on the icons table (or a dereference with + # the delete() method but that will work regardless) key = str(key) + old_icon = await self.generic_get(scope, key, old_icon_hash) await self.delete(old_icon) diff --git a/litecord/schemas.py b/litecord/schemas.py index b6da1e0..c52cf8f 100644 --- a/litecord/schemas.py +++ b/litecord/schemas.py @@ -175,7 +175,12 @@ REGISTER = { 'username': {'type': 'username', 'required': True}, 'email': {'type': 'email', 'required': False}, 'password': {'type': 'string', 'minlength': 5, 'required': False}, - 'invite': {'type': 'string', 'required': False, 'nullable': True}, # optional + + # invite stands for a guild invite, not an instance invite (that's on + # the register_with_invite handler). + 'invite': {'type': 'string', 'required': False, 'nullable': True}, + + # following fields only sent by official client 'fingerprint': {'type': 'string', 'required': False, 'nullable': True}, # these are sent by official client 'captcha_key': {'type': 'string', 'required': False, 'nullable': True}, 'consent': {'type': 'boolean'}, @@ -273,8 +278,19 @@ GUILD_UPDATE = { 'required': False }, 'region': {'type': 'voice_region', 'required': False, 'nullable': True}, + 'icon': {'type': 'b64_icon', 'required': False, 'nullable': True}, - 'splash': {'type': 'b64_icon', 'required': False, 'nullable': True}, + + # TODO: does splash also respect when its just a string pointing to the + # hash, just like in USER_UPDATE.avatar? + 'banner': {'type': 'string', 'required': False, 'nullable': True}, + 'splash': {'type': 'string', 'required': False, 'nullable': True}, + + 'description': { + 'type': 'string', 'required': False, + 'minlength': 1, 'maxlength': 120, + 'nullable': True + }, 'verification_level': { 'type': 'verification_level', 'required': False}, @@ -655,3 +671,9 @@ GET_MENTIONS = { 'everyone': {'coerce': bool, 'default': True}, 'guild_id': {'coerce': int, 'required': False} } + + +VANITY_URL_PATCH = { + # TODO: put proper values in maybe an invite data type + 'code': {'type': 'string', 'minlength': 5, 'maxlength': 30} +} diff --git a/litecord/storage.py b/litecord/storage.py index 1530a65..72812a7 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -146,6 +146,13 @@ class Storage: WHERE username = $1 AND discriminator = $2 """, username, discriminator) + async def guild_features(self, guild_id: int) -> Optional[List[str]]: + """Get a list of guild features for the given guild.""" + return await self.db.fetchval(""" + SELECT features FROM guilds + WHERE id = $1 + """, guild_id) + async def get_guild(self, guild_id: int, user_id=None) -> Optional[Dict]: """Get gulid payload.""" row = await self.db.fetchrow(""" @@ -155,7 +162,8 @@ class Storage: explicit_content_filter, mfa_level, embed_enabled, embed_channel_id::text, widget_enabled, widget_channel_id::text, - system_channel_id::text + system_channel_id::text, features, + banner, description FROM guilds WHERE guilds.id = $1 """, guild_id) @@ -626,7 +634,7 @@ class Storage: 'voice_states': await self.guild_voice_states(guild_id), }} - async def get_guild_full(self, guild_id: int, user_id: int, + async def get_guild_full(self, guild_id: int, user_id: Optional[int] = None, large_count: int = 250) -> Optional[Dict]: """Get full information on a guild. @@ -911,16 +919,14 @@ class Storage: # fetch some guild info guild = await self.db.fetchrow(""" - SELECT id::text, name, splash, icon, verification_level + SELECT id::text, name, icon, splash, banner, features, + verification_level, description FROM guilds WHERE id = $1 """, invite['guild_id']) if guild: dinv['guild'] = dict(guild) - - # TODO: query actual guild features - dinv['guild']['features'] = [] else: dinv['guild'] = {} @@ -1072,3 +1078,12 @@ class Storage: """) return list(map(dict, rows)) + + async def has_feature(self, guild_id: int, feature: str) -> bool: + """Return if a certain guild has a certain feature.""" + features = await self.db.fetchval(""" + SELECT features FROM guilds + WHERE id = $1 + """, guild_id) + + return feature.upper() in features diff --git a/litecord/types.py b/litecord/types.py index 1a845f8..2c9c122 100644 --- a/litecord/types.py +++ b/litecord/types.py @@ -17,6 +17,8 @@ along with this program. If not, see . """ +from typing import Optional + # size units KILOBYTES = 1024 @@ -45,5 +47,6 @@ class Color: return self.value -def timestamp_(dt): +def timestamp_(dt) -> Optional[str]: + """safer version for dt.isoformat()""" return f'{dt.isoformat()}+00:00' if dt else None diff --git a/litecord/utils.py b/litecord/utils.py index 513fafa..d19df06 100644 --- a/litecord/utils.py +++ b/litecord/utils.py @@ -174,3 +174,8 @@ def yield_chunks(input_list: Sequence[Any], chunk_size: int): # make the chunks for idx in range(0, len(input_list), chunk_size): yield input_list[idx:idx + chunk_size] + +def to_update(j: dict, orig: dict, field: str) -> bool: + """Compare values to check if j[field] is actually updating + the value in orig[field]. Useful for icon checks.""" + return field in j and j[field] and j[field] != orig[field] diff --git a/litecord/voice/lvsp_manager.py b/litecord/voice/lvsp_manager.py index 78484e0..9a2eb41 100644 --- a/litecord/voice/lvsp_manager.py +++ b/litecord/voice/lvsp_manager.py @@ -19,6 +19,7 @@ along with this program. If not, see . from typing import Optional from collections import defaultdict +from dataclasses import dataclass from logbook import Logger @@ -26,6 +27,14 @@ from litecord.voice.lvsp_conn import LVSPConnection log = Logger(__name__) + +@dataclass +class Region: + """Voice region data.""" + id: str + vip: bool + + class LVSPManager: """Manager class for Litecord Voice Server Protocol (LVSP) connections. @@ -44,46 +53,52 @@ class LVSPManager: # maps Union[GuildID, DMId, GroupDMId] to server hostnames self.assign = {} + # quick storage for Region dataclass instances. + self._regions = {} + self.app.loop.create_task(self._spawn()) async def _spawn(self): """Spawn LVSPConnection for each region.""" regions = await self.app.db.fetch(""" - SELECT id + SELECT id, vip FROM voice_regions WHERE deprecated = false """) - regions = [r['id'] for r in regions] + regions = [Region(r['id'], r['vip']) for r in regions] if not regions: log.warning('no regions are setup') return for region in regions: + # store it locally for region() function + self._regions[region.id] = region + self.app.loop.create_task( self._spawn_region(region) ) - async def _spawn_region(self, region: str): + async def _spawn_region(self, region: Region): """Spawn a region. Involves fetching all the hostnames for the regions and spawning a LVSPConnection for each.""" servers = await self.app.db.fetch(""" SELECT hostname FROM voice_servers WHERE region_id = $1 - """, region) + """, region.id) if not servers: log.warning('region {} does not have servers', region) return servers = [r['hostname'] for r in servers] - self.servers[region] = servers + self.servers[region.id] = servers for hostname in servers: - conn = LVSPConnection(self, region, hostname) + conn = LVSPConnection(self, region.id, hostname) self.conns[hostname] = conn self.app.loop.create_task( @@ -144,3 +159,7 @@ class LVSPManager: async def assign_conn(self, key: int, hostname: str): """Assign a connection to a given key in the assign map""" self.assign[key] = hostname + + def region(self, region_id: str) -> Optional[Region]: + """Get a :class:`Region` instance:wq:wq""" + return self._regions.get(region_id) diff --git a/manage/cmd/migration/scripts/12_remove_features_table.sql b/manage/cmd/migration/scripts/12_remove_features_table.sql new file mode 100644 index 0000000..7c8df16 --- /dev/null +++ b/manage/cmd/migration/scripts/12_remove_features_table.sql @@ -0,0 +1,5 @@ +DROP TABLE guild_features; +DROP TABLE features; + +-- this should do the trick +ALTER TABLE guilds ADD COLUMN features text[] NOT NULL DEFAULT '{}'; diff --git a/manage/cmd/migration/scripts/13_add_vanity_invites_table.sql b/manage/cmd/migration/scripts/13_add_vanity_invites_table.sql new file mode 100644 index 0000000..73aa914 --- /dev/null +++ b/manage/cmd/migration/scripts/13_add_vanity_invites_table.sql @@ -0,0 +1,5 @@ +-- vanity url table, the mapping is 1-1 for guilds and vanity urls +CREATE TABLE IF NOT EXISTS vanity_invites ( + guild_id bigint REFERENCES guilds (id) PRIMARY KEY, + code text REFERENCES invites (code) ON DELETE CASCADE +); diff --git a/manage/cmd/migration/scripts/14_add_guild_description.sql b/manage/cmd/migration/scripts/14_add_guild_description.sql new file mode 100644 index 0000000..c9dbecb --- /dev/null +++ b/manage/cmd/migration/scripts/14_add_guild_description.sql @@ -0,0 +1,2 @@ +ALTER TABLE guilds ADD COLUMN description text DEFAULT NULL; +ALTER TABLE guilds ADD COLUMN banner text DEFAULT NULL; diff --git a/run.py b/run.py index 398896d..15ce2b4 100644 --- a/run.py +++ b/run.py @@ -57,7 +57,8 @@ from litecord.blueprints.user import ( from litecord.blueprints.user.billing_job import payment_job from litecord.blueprints.admin_api import ( - voice as voice_admin + voice as voice_admin, features as features_admin, + guilds as guilds_admin ) from litecord.blueprints.admin_api.voice import guild_region_check @@ -143,7 +144,9 @@ def set_blueprints(app_): nodeinfo: -1, static: -1, - voice_admin: '/admin/voice' + voice_admin: '/admin/voice', + features_admin: '/admin/guilds', + guilds_admin: '/admin/guilds' } for bp, suffix in bps.items(): diff --git a/schema.sql b/schema.sql index 269ff91..32fa564 100644 --- a/schema.sql +++ b/schema.sql @@ -362,12 +362,13 @@ CREATE TABLE IF NOT EXISTS guilds ( region text NOT NULL REFERENCES voice_regions (id), - /* default no afk channel - afk channel is voice-only. - */ + features text[], + + -- default no afk channel + -- afk channel is voice-only. afk_channel_id bigint REFERENCES channels (id) DEFAULT NULL, - /* default 5 minutes */ + -- default 5 minutes afk_timeout int DEFAULT 300, -- from 0 to 4 @@ -482,20 +483,6 @@ CREATE TABLE IF NOT EXISTS group_dm_members ( ); - - -CREATE TABLE IF NOT EXISTS features ( - id serial PRIMARY KEY, - feature text NOT NULL -); - -CREATE TABLE IF NOT EXISTS guild_features ( - guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, - feature integer REFERENCES features (id), - PRIMARY KEY (guild_id, feature) -); - - CREATE TABLE IF NOT EXISTS guild_integrations ( guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, user_id bigint REFERENCES users (id) ON DELETE CASCADE, @@ -540,6 +527,12 @@ CREATE TABLE IF NOT EXISTS invites ( revoked bool DEFAULT false ); +-- vanity url table, the mapping is 1-1 for guilds and vanity urls +CREATE TABLE IF NOT EXISTS vanity_invites ( + guild_id bigint REFERENCES guilds (id) PRIMARY KEY, + code text REFERENCES invites (code) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS webhooks ( id bigint PRIMARY KEY,