Merge branch 'guild-features' into 'master'

Guild features

Closes #37 and #36

See merge request litecord/litecord!25
This commit is contained in:
Luna 2019-03-15 19:37:26 +00:00
commit 681dee4306
23 changed files with 619 additions and 89 deletions

View File

@ -2,7 +2,9 @@
the base path is `/api/v6/admin`. the base path is `/api/v6/admin`.
## GET `/voice/regions/<region>` ## Voice
### GET `/voice/regions/<region>`
Return a list of voice server objects for the region. 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 | | hostname | string | the hostname of the voice server |
| last\_health | float | the health of the voice server | | last\_health | float | the health of the voice server |
## PUT `/voice/regions` ### PUT `/voice/regions`
Create a voice region. 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 | | deprecated | Optional[bool] | if voice region is deprecated, default false |
| custom | Optional[bool] | if voice region is custom-only, default false | | custom | Optional[bool] | if voice region is custom-only, default false |
## PUT `/voice/regions/<region>/server` ### PUT `/voice/regions/<region>/server`
Create a voice server for a region. 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 | | hostname | string | the hostname of the voice server |
## PUT `/voice/regions/<region>/deprecate` ### PUT `/voice/regions/<region>/deprecate`
Mark a voice region as deprecated. Disables any voice actions on guilds that are Mark a voice region as deprecated. Disables any voice actions on guilds that are
using the voice region. using the voice region.
Returns empty body with 204 status code on success. Returns empty body with 204 status code on success.
## Guilds
### GET `/guilds/<guild_id>`
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/<guild_id>/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/<guild_id>/features`
Insert features. Receives and returns the same structure as
PATCH `/guilds/<guild_id>/features`.
### DELETE `/guilds/<guild_id>/features`
Remove features. Receives and returns the same structure as
PATCH `/guilds/<guild_id>/features`.

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from litecord.enums import Feature
VOICE_SERVER = { VOICE_SERVER = {
'hostname': {'type': 'string', 'maxlength': 255, 'required': True} 'hostname': {'type': 'string', 'maxlength': 255, 'required': True}
} }
@ -29,3 +31,12 @@ VOICE_REGION = {
'deprecated': {'type': 'boolean', 'default': False}, 'deprecated': {'type': 'boolean', 'default': False},
'custom': {'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)}
}
}

View File

@ -18,5 +18,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 .guilds import bp as guilds
__all__ = ['voice'] __all__ = ['voice', 'features', 'guilds']

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
from quart import Blueprint, jsonify, current_app as app
from litecord.auth import admin_check
bp = Blueprint('guilds_admin', __name__)
@bp.route('/<int:guild_id>', 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)
)

View File

@ -23,6 +23,7 @@ import secrets
import itsdangerous import itsdangerous
import bcrypt import bcrypt
from quart import Blueprint, jsonify, request, current_app as app from quart import Blueprint, jsonify, request, current_app as app
from logbook import Logger
from litecord.auth import token_check, create_user from litecord.auth import token_check, create_user
from litecord.schemas import validate, REGISTER, REGISTER_WITH_INVITE 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 litecord.snowflake import get_snowflake
from .invites import use_invite from .invites import use_invite
log = Logger(__name__)
bp = Blueprint('auth', __name__) bp = Blueprint('auth', __name__)
@ -64,10 +65,17 @@ async def register():
j = await request.get_json() j = await request.get_json()
if not 'password' in j: 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) 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( new_id, pwd_hash = await create_user(
username, email, password, app.db username, email, password, app.db
@ -76,9 +84,9 @@ async def register():
if invite: if invite:
try: try:
await use_invite(new_id, invite) await use_invite(new_id, invite)
except Exception as e: except Exception:
print(e) log.exception('failed to use invite for register {} {!r}',
pass # do nothing new_id, invite)
return jsonify({ return jsonify({
'token': make_token(new_id, pwd_hash) 'token': make_token(new_id, pwd_hash)

View File

@ -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.schemas import validate, NEW_EMOJI, PATCH_EMOJI
from litecord.snowflake import get_snowflake from litecord.snowflake import get_snowflake
from litecord.types import KILOBYTES from litecord.types import KILOBYTES
from litecord.images import parse_data_uri
from litecord.errors import BadRequest
bp = Blueprint('guild.emoji', __name__) 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('/<int:guild_id>/emojis', methods=['POST']) @bp.route('/<int:guild_id>/emojis', methods=['POST'])
async def _put_emoji(guild_id): async def _put_emoji(guild_id):
user_id = await token_check() user_id = await token_check()
@ -63,6 +83,11 @@ async def _put_emoji(guild_id):
j = validate(await request.get_json(), NEW_EMOJI) 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() emoji_id = get_snowflake()
icon = await app.icons.put( icon = await app.icons.put(
@ -75,6 +100,8 @@ async def _put_emoji(guild_id):
if not icon: if not icon:
return '', 400 return '', 400
# TODO: better way to detect animated emoji rather than just gifs,
# maybe a list perhaps?
await app.db.execute( await app.db.execute(
""" """
INSERT INTO guild_emoji INSERT INTO guild_emoji
@ -85,7 +112,8 @@ async def _put_emoji(guild_id):
emoji_id, guild_id, user_id, emoji_id, guild_id, user_id,
j['name'], j['name'],
icon.icon_hash, icon.icon_hash,
icon.mime == 'image/gif') icon.mime == 'image/gif'
)
await _dispatch_emojis(guild_id) await _dispatch_emojis(guild_id)

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import Optional
from quart import Blueprint, request, current_app as app, jsonify from quart import Blueprint, request, current_app as app, jsonify
from litecord.blueprints.guild.channels import create_guild_channel from litecord.blueprints.guild.channels import create_guild_channel
@ -28,10 +30,12 @@ from ..auth import token_check
from ..snowflake import get_snowflake from ..snowflake import get_snowflake
from ..enums import ChannelType from ..enums import ChannelType
from ..schemas import ( 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 .channels import channel_ack
from .checks import guild_check, guild_owner_check, guild_perm_check from .checks import guild_check, guild_owner_check, guild_perm_check
from litecord.utils import to_update
from litecord.errors import BadRequest 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) await create_guild_channel(guild_id, channel_id, ctype)
async def put_guild_icon(guild_id: int, icon: str): def sanitize_icon(icon: Optional[str]) -> Optional[str]:
"""Insert a guild icon on the icon database.""" """Return sanitized version of the given icon.
Defaults to a jpeg icon when the header isn't given.
"""
if icon and icon.startswith('data'): if icon and icon.startswith('data'):
encoded = icon return icon
else:
encoded = (f'data:image/jpeg;base64,{icon}' return (f'data:image/jpeg;base64,{icon}'
if icon if icon
else None) 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( 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']) @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('/<int:guild_id>', methods=['PATCH']) @bp.route('/<int:guild_id>', methods=['PATCH'])
async def _update_guild(guild_id): async def _update_guild(guild_id):
user_id = await token_check() user_id = await token_check()
@ -220,26 +279,32 @@ async def _update_guild(guild_id):
""", j['name'], guild_id) """, j['name'], guild_id)
if 'region' in j: if 'region' in j:
await app.db.execute(""" region = app.voice.lvsp.region(j['region'])
UPDATE guilds
SET region = $1 if region is not None:
WHERE id = $2 await _guild_update_region(guild_id, region)
""", j['region'], guild_id)
if 'icon' in j: if 'icon' in j:
# delete old await _guild_update_icon(
new_icon = await app.icons.update( 'guild', guild_id, j['icon'], size=(128, 128))
'guild', guild_id, j['icon'], always_icon=True
)
await app.db.execute(""" # small guild to work with to_update()
UPDATE guilds guild = await app.storage.get_guild(guild_id)
SET icon = $1
WHERE id = $2 if to_update(j, guild, 'splash'):
""", new_icon.icon_hash, guild_id) 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', 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]: for field in [f for f in fields if f in j]:
await app.db.execute(f""" await app.db.execute(f"""
@ -371,3 +436,95 @@ async def ack_guild(guild_id):
await channel_ack(user_id, guild_id, chan_id) await channel_ack(user_id, guild_id, chan_id)
return '', 204 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('/<int:guild_id>/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('/<int:guild_id>/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)
)

View File

@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from os.path import splitext 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__) 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) return await send_icon('guild', guild_id, icon_hash, ext=ext)
@bp.route('/splashes/<int:guild_id>/<icon_hash>.<ext>', methods=['GET'])
async def _get_guild_splash(guild_id: int, splash_hash: str, ext: str):
pass
@bp.route('/embed/avatars/<int:discrim>.png') @bp.route('/embed/avatars/<int:discrim>.png')
async def _get_default_user_avatar(discrim: int): async def _get_default_user_avatar(discrim: int):
pass pass
@ -65,11 +60,8 @@ async def _get_default_user_avatar(discrim: int):
@bp.route('/avatars/<int:user_id>/<avatar_file>') @bp.route('/avatars/<int:user_id>/<avatar_file>')
async def _get_user_avatar(user_id, avatar_file): 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) avatar_hash, ext = splitext_(avatar_file)
return await send_icon( return await send_icon('user', user_id, avatar_hash, ext=ext)
'user', user_id, avatar_hash, ext=ext)
# @bp.route('/app-icons/<int:application_id>/<icon_hash>.<ext>') # @bp.route('/app-icons/<int:application_id>/<icon_hash>.<ext>')
@ -78,6 +70,18 @@ async def get_app_icon(application_id, icon_hash, ext):
@bp.route('/channel-icons/<int:channel_id>/<icon_file>', methods=['GET']) @bp.route('/channel-icons/<int:channel_id>/<icon_file>', 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) 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/<int:guild_id>/<icon_file>', 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/<int:guild_id>/<icon_file>', 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)

View File

@ -36,6 +36,7 @@ from litecord.images import parse_data_uri
from litecord.permissions import base_permissions from litecord.permissions import base_permissions
from litecord.blueprints.auth import check_password from litecord.blueprints.auth import check_password
from litecord.utils import to_update
bp = Blueprint('user', __name__) bp = Blueprint('user', __name__)
log = Logger(__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): async def _check_pass(j, user):
# Do not do password checks on unclaimed accounts # Do not do password checks on unclaimed accounts
if user['email'] is None: if user['email'] is None:

View File

@ -18,12 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
import inspect import inspect
from typing import List, Any
from enum import Enum, IntEnum from enum import Enum, IntEnum
class EasyEnum(Enum): class EasyEnum(Enum):
"""Wrapper around the enum class for convenience."""
@classmethod @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()] return [v.value for v in cls.__members__.values()]
@ -197,12 +201,27 @@ class RelationshipType(EasyEnum):
class MessageNotifications(EasyEnum): class MessageNotifications(EasyEnum):
"""Message notifications"""
ALL = 0 ALL = 0
MENTIONS = 1 MENTIONS = 1
NOTHING = 2 NOTHING = 2
class PremiumType: class PremiumType:
"""Premium (Nitro) type."""
TIER_1 = 1 TIER_1 = 1
TIER_2 = 2 TIER_2 = 2
NONE = None 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'

View File

@ -74,7 +74,16 @@ def decode_json(data: str):
def encode_etf(payload) -> 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): def _etf_decode_dict(data):

View File

@ -46,6 +46,7 @@ EXTENSIONS = {
MIMES = { MIMES = {
'jpg': 'image/jpeg', 'jpg': 'image/jpeg',
'jpe': 'image/jpeg', 'jpe': 'image/jpeg',
'jpeg': 'image/jpeg',
'webp': 'image/webp', 'webp': 'image/webp',
} }
@ -171,15 +172,23 @@ def parse_data_uri(string) -> tuple:
def _gen_update_sql(scope: str) -> str: def _gen_update_sql(scope: str) -> str:
# match a scope to (table, field)
field = { field = {
'user': 'avatar', 'user': 'avatar',
'guild': 'icon', 'guild': 'icon',
'splash': 'splash',
'banner': 'banner',
'channel-icons': 'icon', 'channel-icons': 'icon',
}[scope] }[scope]
table = { table = {
'user': 'users', 'user': 'users',
'guild': 'guilds', 'guild': 'guilds',
'splash': 'guilds',
'banner': 'guilds',
'channel-icons': 'group_dm_channels' 'channel-icons': 'group_dm_channels'
}[scope] }[scope]
@ -269,6 +278,8 @@ class IconManager:
self.storage = app.storage self.storage = app.storage
async def _convert_ext(self, icon: Icon, target: str): async def _convert_ext(self, icon: Icon, target: str):
target = 'jpeg' if target == 'jpg' else target
target_mime = get_mime(target) target_mime = get_mime(target)
log.info('converting from {} to {}', icon.mime, target_mime) log.info('converting from {} to {}', icon.mime, target_mime)
@ -279,6 +290,10 @@ class IconManager:
image = Image.open(icon.as_path) image = Image.open(icon.as_path)
target_fd = target_path.open('wb') target_fd = target_path.open('wb')
if target == 'jpeg':
image = image.convert('RGB')
image.save(target_fd, format=target) image.save(target_fd, format=target)
target_fd.close() target_fd.close()
@ -386,6 +401,9 @@ class IconManager:
if scope == 'user' and mime == 'image/gif': if scope == 'user' and mime == 'image/gif':
icon_hash = f'a_{icon_hash}' icon_hash = f'a_{icon_hash}'
log.debug('PUT icon {!r} {!r} {!r} {!r}',
scope, key, icon_hash, mime)
await self.storage.db.execute(""" await self.storage.db.execute("""
INSERT INTO icons (scope, key, hash, mime) INSERT INTO icons (scope, key, hash, mime)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
@ -406,6 +424,9 @@ class IconManager:
if not icon: if not icon:
return return
log.debug('DEL {}',
icon)
# dereference # dereference
await self.storage.db.execute(""" await self.storage.db.execute("""
UPDATE users UPDATE users
@ -430,6 +451,18 @@ class IconManager:
WHERE icon = $1 WHERE icon = $1
""", icon.icon_hash) """, 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(""" await self.storage.db.execute("""
UPDATE group_dm_channels UPDATE group_dm_channels
SET icon = NULL SET icon = NULL
@ -455,7 +488,11 @@ class IconManager:
old_icon_hash = await self.storage.db.fetchval( old_icon_hash = await self.storage.db.fetchval(
_gen_update_sql(scope), key) _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) key = str(key)
old_icon = await self.generic_get(scope, key, old_icon_hash) old_icon = await self.generic_get(scope, key, old_icon_hash)
await self.delete(old_icon) await self.delete(old_icon)

View File

@ -175,7 +175,12 @@ REGISTER = {
'username': {'type': 'username', 'required': True}, 'username': {'type': 'username', 'required': True},
'email': {'type': 'email', 'required': False}, 'email': {'type': 'email', 'required': False},
'password': {'type': 'string', 'minlength': 5, '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 'fingerprint': {'type': 'string', 'required': False, 'nullable': True}, # these are sent by official client
'captcha_key': {'type': 'string', 'required': False, 'nullable': True}, 'captcha_key': {'type': 'string', 'required': False, 'nullable': True},
'consent': {'type': 'boolean'}, 'consent': {'type': 'boolean'},
@ -273,8 +278,19 @@ GUILD_UPDATE = {
'required': False 'required': False
}, },
'region': {'type': 'voice_region', 'required': False, 'nullable': True}, 'region': {'type': 'voice_region', 'required': False, 'nullable': True},
'icon': {'type': 'b64_icon', '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': { 'verification_level': {
'type': 'verification_level', 'required': False}, 'type': 'verification_level', 'required': False},
@ -655,3 +671,9 @@ GET_MENTIONS = {
'everyone': {'coerce': bool, 'default': True}, 'everyone': {'coerce': bool, 'default': True},
'guild_id': {'coerce': int, 'required': False} '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}
}

View File

@ -146,6 +146,13 @@ class Storage:
WHERE username = $1 AND discriminator = $2 WHERE username = $1 AND discriminator = $2
""", username, discriminator) """, 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]: async def get_guild(self, guild_id: int, user_id=None) -> Optional[Dict]:
"""Get gulid payload.""" """Get gulid payload."""
row = await self.db.fetchrow(""" row = await self.db.fetchrow("""
@ -155,7 +162,8 @@ class Storage:
explicit_content_filter, mfa_level, explicit_content_filter, mfa_level,
embed_enabled, embed_channel_id::text, embed_enabled, embed_channel_id::text,
widget_enabled, widget_channel_id::text, widget_enabled, widget_channel_id::text,
system_channel_id::text system_channel_id::text, features,
banner, description
FROM guilds FROM guilds
WHERE guilds.id = $1 WHERE guilds.id = $1
""", guild_id) """, guild_id)
@ -626,7 +634,7 @@ class Storage:
'voice_states': await self.guild_voice_states(guild_id), '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]: large_count: int = 250) -> Optional[Dict]:
"""Get full information on a guild. """Get full information on a guild.
@ -911,16 +919,14 @@ class Storage:
# fetch some guild info # fetch some guild info
guild = await self.db.fetchrow(""" 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 FROM guilds
WHERE id = $1 WHERE id = $1
""", invite['guild_id']) """, invite['guild_id'])
if guild: if guild:
dinv['guild'] = dict(guild) dinv['guild'] = dict(guild)
# TODO: query actual guild features
dinv['guild']['features'] = []
else: else:
dinv['guild'] = {} dinv['guild'] = {}
@ -1072,3 +1078,12 @@ class Storage:
""") """)
return list(map(dict, rows)) 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

View File

@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import Optional
# size units # size units
KILOBYTES = 1024 KILOBYTES = 1024
@ -45,5 +47,6 @@ class Color:
return self.value 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 return f'{dt.isoformat()}+00:00' if dt else None

View File

@ -174,3 +174,8 @@ def yield_chunks(input_list: Sequence[Any], chunk_size: int):
# make the chunks # make the chunks
for idx in range(0, len(input_list), chunk_size): for idx in range(0, len(input_list), chunk_size):
yield input_list[idx:idx + 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]

View File

@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional from typing import Optional
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from logbook import Logger from logbook import Logger
@ -26,6 +27,14 @@ from litecord.voice.lvsp_conn import LVSPConnection
log = Logger(__name__) log = Logger(__name__)
@dataclass
class Region:
"""Voice region data."""
id: str
vip: bool
class LVSPManager: class LVSPManager:
"""Manager class for Litecord Voice Server Protocol (LVSP) connections. """Manager class for Litecord Voice Server Protocol (LVSP) connections.
@ -44,46 +53,52 @@ class LVSPManager:
# maps Union[GuildID, DMId, GroupDMId] to server hostnames # maps Union[GuildID, DMId, GroupDMId] to server hostnames
self.assign = {} self.assign = {}
# quick storage for Region dataclass instances.
self._regions = {}
self.app.loop.create_task(self._spawn()) self.app.loop.create_task(self._spawn())
async def _spawn(self): async def _spawn(self):
"""Spawn LVSPConnection for each region.""" """Spawn LVSPConnection for each region."""
regions = await self.app.db.fetch(""" regions = await self.app.db.fetch("""
SELECT id SELECT id, vip
FROM voice_regions FROM voice_regions
WHERE deprecated = false WHERE deprecated = false
""") """)
regions = [r['id'] for r in regions] regions = [Region(r['id'], r['vip']) for r in regions]
if not regions: if not regions:
log.warning('no regions are setup') log.warning('no regions are setup')
return return
for region in regions: for region in regions:
# store it locally for region() function
self._regions[region.id] = region
self.app.loop.create_task( self.app.loop.create_task(
self._spawn_region(region) 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 """Spawn a region. Involves fetching all the hostnames
for the regions and spawning a LVSPConnection for each.""" for the regions and spawning a LVSPConnection for each."""
servers = await self.app.db.fetch(""" servers = await self.app.db.fetch("""
SELECT hostname SELECT hostname
FROM voice_servers FROM voice_servers
WHERE region_id = $1 WHERE region_id = $1
""", region) """, region.id)
if not servers: if not servers:
log.warning('region {} does not have servers', region) log.warning('region {} does not have servers', region)
return return
servers = [r['hostname'] for r in servers] servers = [r['hostname'] for r in servers]
self.servers[region] = servers self.servers[region.id] = servers
for hostname in servers: for hostname in servers:
conn = LVSPConnection(self, region, hostname) conn = LVSPConnection(self, region.id, hostname)
self.conns[hostname] = conn self.conns[hostname] = conn
self.app.loop.create_task( self.app.loop.create_task(
@ -144,3 +159,7 @@ class LVSPManager:
async def assign_conn(self, key: int, hostname: str): async def assign_conn(self, key: int, hostname: str):
"""Assign a connection to a given key in the assign map""" """Assign a connection to a given key in the assign map"""
self.assign[key] = hostname 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)

View File

@ -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 '{}';

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE guilds ADD COLUMN description text DEFAULT NULL;
ALTER TABLE guilds ADD COLUMN banner text DEFAULT NULL;

7
run.py
View File

@ -57,7 +57,8 @@ from litecord.blueprints.user import (
from litecord.blueprints.user.billing_job import payment_job 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 voice as voice_admin, features as features_admin,
guilds as guilds_admin
) )
from litecord.blueprints.admin_api.voice import guild_region_check from litecord.blueprints.admin_api.voice import guild_region_check
@ -143,7 +144,9 @@ def set_blueprints(app_):
nodeinfo: -1, nodeinfo: -1,
static: -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(): for bp, suffix in bps.items():

View File

@ -362,12 +362,13 @@ CREATE TABLE IF NOT EXISTS guilds (
region text NOT NULL REFERENCES voice_regions (id), region text NOT NULL REFERENCES voice_regions (id),
/* default no afk channel features text[],
afk channel is voice-only.
*/ -- default no afk channel
-- afk channel is voice-only.
afk_channel_id bigint REFERENCES channels (id) DEFAULT NULL, afk_channel_id bigint REFERENCES channels (id) DEFAULT NULL,
/* default 5 minutes */ -- default 5 minutes
afk_timeout int DEFAULT 300, afk_timeout int DEFAULT 300,
-- from 0 to 4 -- 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 ( CREATE TABLE IF NOT EXISTS guild_integrations (
guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE, guild_id bigint REFERENCES guilds (id) ON DELETE CASCADE,
user_id bigint REFERENCES users (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 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 ( CREATE TABLE IF NOT EXISTS webhooks (
id bigint PRIMARY KEY, id bigint PRIMARY KEY,