litecord/litecord/schemas.py

738 lines
21 KiB
Python

"""
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 re
from typing import Union, Dict, List
from cerberus import Validator
from logbook import Logger
from .errors import BadRequest
from .permissions import Permissions
from .types import Color
from .enums import (
ActivityType, StatusType, ExplicitFilter, RelationshipType,
MessageNotifications, ChannelType, VerificationLevel
)
from litecord.embed.schemas import EMBED_OBJECT, EmbedURL
log = Logger(__name__)
USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_ ]{2,30}$', re.A)
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$',
re.A)
DATA_REGEX = re.compile(r'data\:image/(png|jpeg|gif);base64,(.+)', re.A)
# collection of regexes
USER_MENTION = re.compile(r'<@!?(\d+)>', re.A | re.M)
CHAN_MENTION = re.compile(r'<#(\d+)>', re.A | re.M)
ROLE_MENTION = re.compile(r'<@&(\d+)>', re.A | re.M)
EMOJO_MENTION = re.compile(r'<:(\.+):(\d+)>', re.A | re.M)
ANIMOJI_MENTION = re.compile(r'<a:(\.+):(\d+)>', re.A | re.M)
def _in_enum(enum, value) -> bool:
"""Return if a given value is in the enum."""
try:
enum(value)
return True
except ValueError:
return False
class LitecordValidator(Validator):
"""Main validator class for Litecord, containing custom types."""
def _validate_type_username(self, value: str) -> bool:
"""Validate against the username regex."""
return bool(USERNAME_REGEX.match(value))
def _validate_type_email(self, value: str) -> bool:
"""Validate against the email regex."""
return bool(EMAIL_REGEX.match(value))
def _validate_type_b64_icon(self, value: str) -> bool:
return bool(DATA_REGEX.match(value))
def _validate_type_discriminator(self, value: str) -> bool:
"""Discriminators are numbers in the API
that can go from 0 to 9999.
"""
try:
discrim = int(value)
except (TypeError, ValueError):
return False
return 0 < discrim <= 9999
def _validate_type_snowflake(self, value: str) -> bool:
try:
int(value)
return True
except ValueError:
return False
def _validate_type_voice_region(self, value: str) -> bool:
# TODO: call voice manager for regions instead of hardcoding
# I'm sure the context would be there at least in a basic level, so
# we can access the app.
return value.lower() in ('brazil', 'us-east', 'us-west',
'us-south', 'russia')
def _validate_type_verification_level(self, value: int) -> bool:
return _in_enum(VerificationLevel, value)
def _validate_type_activity_type(self, value: int) -> bool:
return value in ActivityType.values()
def _validate_type_channel_type(self, value: int) -> bool:
return value in ChannelType.values()
def _validate_type_status_external(self, value: str) -> bool:
statuses = StatusType.values()
return value in statuses
def _validate_type_explicit(self, value: str) -> bool:
try:
val = int(value)
except (TypeError, ValueError):
return False
return val in ExplicitFilter.values()
def _validate_type_rel_type(self, value: str) -> bool:
try:
val = int(value)
except (TypeError, ValueError):
return False
# nobody is allowed to use the INCOMING and OUTGOING rel types
return val in (RelationshipType.FRIEND.value,
RelationshipType.BLOCK.value)
def _validate_type_msg_notifications(self, value: str):
try:
val = int(value)
except (TypeError, ValueError):
return False
return val in MessageNotifications.values()
def _validate_type_guild_name(self, value: str) -> bool:
return 2 <= len(value) <= 100
def _validate_type_role_name(self, value: str) -> bool:
return 1 <= len(value) <= 100
def _validate_type_channel_name(self, value: str) -> bool:
# for now, we'll use the same validation for guild_name
return self._validate_type_guild_name(value)
def _validate_type_theme(self, value: str) -> bool:
return value in ['light', 'dark']
def _validate_type_nickname(self, value: str) -> bool:
return isinstance(value, str) and (len(value) < 32)
def validate(reqjson: Union[Dict, List], schema: Dict,
raise_err: bool = True) -> Dict:
"""Validate the given user-given data against a schema, giving the
"correct" version of the document, with all defaults applied.
Parameters
----------
reqjson:
The input data
schema:
The schema to validate reqjson against
raise_err:
If we should raise a BadRequest error when the validation
fails. Default is true.
"""
validator = LitecordValidator(schema)
try:
valid = validator.validate(reqjson)
except Exception:
log.exception('Error while validating')
raise Exception(f'Error while validating: {reqjson}')
if not valid:
errs = validator.errors
log.warning('Error validating doc {!r}: {!r}', reqjson, errs)
if raise_err:
raise BadRequest('bad payload', errs)
return None
return validator.document
REGISTER = {
'username': {'type': 'username', 'required': True},
'email': {'type': 'email', 'required': False},
'password': {'type': 'string', 'minlength': 5, 'required': False},
# 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'},
'gift_code_sku_id': {'type': 'string', 'required': False, 'nullable': True}
}
# only used by us, not discord, hence 'invcode' (to separate from discord)
REGISTER_WITH_INVITE = {**REGISTER, **{
'invcode': {'type': 'string', 'required': True}
}}
USER_UPDATE = {
'username': {
'type': 'username', 'minlength': 2,
'maxlength': 30, 'required': False},
'discriminator': {
'type': 'discriminator',
'required': False,
'nullable': True,
},
'password': {
'type': 'string', 'minlength': 0,
'maxlength': 100, 'required': False,
},
'new_password': {
'type': 'string', 'minlength': 5,
'maxlength': 100, 'required': False,
'dependencies': 'password',
'nullable': True
},
'email': {
'type': 'string', 'minlength': 2,
'maxlength': 30, 'required': False,
'dependencies': 'password',
},
'avatar': {
# can be both b64_icon or string (just the hash)
'type': 'string', 'required': False,
'nullable': True
},
}
PARTIAL_ROLE_GUILD_CREATE = {
'type': 'dict',
'schema': {
'name': {'type': 'role_name'},
'color': {'type': 'number', 'default': 0},
'hoist': {'type': 'boolean', 'default': False},
# NOTE: no position on partial role (on guild create)
'permissions': {'coerce': Permissions, 'required': False},
'mentionable': {'type': 'boolean', 'default': False},
}
}
PARTIAL_CHANNEL_GUILD_CREATE = {
'type': 'dict',
'schema': {
'name': {'type': 'channel_name'},
'type': {'type': 'channel_type'},
}
}
GUILD_CREATE = {
'name': {'type': 'guild_name'},
'region': {'type': 'voice_region', 'nullable': True},
'icon': {'type': 'b64_icon', 'required': False, 'nullable': True},
'verification_level': {
'type': 'verification_level', 'default': 0},
'default_message_notifications': {
'type': 'msg_notifications', 'default': 0},
'explicit_content_filter': {
'type': 'explicit', 'default': 0},
'roles': {
'type': 'list', 'required': False,
'schema': PARTIAL_ROLE_GUILD_CREATE},
'channels': {
'type': 'list', 'default': [], 'schema': PARTIAL_CHANNEL_GUILD_CREATE},
}
GUILD_UPDATE = {
'name': {
'type': 'guild_name',
'required': False
},
'region': {'type': 'voice_region', 'required': False, 'nullable': True},
# all three can have hashes
'icon': {'type': 'string', 'required': False, 'nullable': True},
'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},
'default_message_notifications': {
'type': 'msg_notifications', 'required': False},
'explicit_content_filter': {'type': 'explicit', 'required': False},
'afk_channel_id': {
'type': 'snowflake', 'required': False, 'nullable': True},
'afk_timeout': {'type': 'number', 'required': False},
'owner_id': {'type': 'snowflake', 'required': False},
'system_channel_id': {
'type': 'snowflake', 'required': False, 'nullable': True},
}
CHAN_OVERWRITE = {
'id': {'coerce': int},
'type': {'type': 'string', 'allowed': ['role', 'member']},
'allow': {'coerce': Permissions},
'deny': {'coerce': Permissions}
}
CHAN_CREATE = {
'name': {
'type': 'string', 'minlength': 2,
'maxlength': 100, 'required': True
},
'type': {'type': 'channel_type',
'default': ChannelType.GUILD_TEXT.value},
'position': {'coerce': int, 'required': False},
'topic': {
'type': 'string', 'minlength': 0,
'maxlength': 1024, 'required': False},
'nsfw': {'type': 'boolean', 'required': False},
'rate_limit_per_user': {
'coerce': int, 'min': 0,
'max': 120, 'required': False},
'bitrate': {
'coerce': int, 'min': 8000,
# NOTE: 'max' is 96000 for non-vip guilds
'max': 128000, 'required': False},
'user_limit': {
# user_limit being 0 means infinite.
'coerce': int, 'min': 0,
'max': 99, 'required': False
},
'permission_overwrites': {
'type': 'list',
'schema': {'type': 'dict', 'schema': CHAN_OVERWRITE},
'required': False
},
'parent_id': {'coerce': int, 'required': False, 'nullable': True}
}
CHAN_UPDATE = {**CHAN_CREATE, **{
'name': {
'type': 'string', 'minlength': 2,
'maxlength': 100, 'required': False},
}}
ROLE_CREATE = {
'name': {'type': 'string', 'default': 'new role'},
'permissions': {'coerce': Permissions, 'nullable': True},
'color': {'coerce': Color, 'default': 0},
'hoist': {'type': 'boolean', 'default': False},
'mentionable': {'type': 'boolean', 'default': False},
}
ROLE_UPDATE = {
'name': {'type': 'string', 'required': False},
'permissions': {'coerce': Permissions, 'required': False},
'color': {'coerce': Color, 'required': False},
'hoist': {'type': 'boolean', 'required': False},
'mentionable': {'type': 'boolean', 'required': False},
}
ROLE_UPDATE_POSITION = {
'roles': {
'type': 'list',
'schema': {
'type': 'dict',
'schema': {
'id': {'coerce': int},
'position': {'coerce': int},
},
}
}
}
MEMBER_UPDATE = {
'nick': {
'type': 'nickname', 'required': False},
'roles': {'type': 'list', 'required': False,
'schema': {'coerce': int}},
'mute': {'type': 'boolean', 'required': False},
'deaf': {'type': 'boolean', 'required': False},
'channel_id': {'type': 'snowflake', 'required': False},
}
# NOTE: things such as payload_json are parsed at the handler
# for creating a message.
MESSAGE_CREATE = {
'content': {'type': 'string', 'minlength': 0, 'maxlength': 2000},
'nonce': {'type': 'snowflake', 'required': False},
'tts': {'type': 'boolean', 'required': False},
'embed': {
'type': 'dict',
'schema': EMBED_OBJECT,
'required': False
}
}
GW_ACTIVITY = {
'name': {'type': 'string', 'required': True},
'type': {'type': 'activity_type', 'required': True},
'url': {'type': 'string', 'required': False, 'nullable': True},
'timestamps': {
'type': 'dict',
'required': False,
'schema': {
'start': {'type': 'number', 'required': False},
'end': {'type': 'number', 'required': False},
},
},
'application_id': {'type': 'snowflake', 'required': False,
'nullable': False},
'details': {'type': 'string', 'required': False, 'nullable': True},
'state': {'type': 'string', 'required': False, 'nullable': True},
'party': {
'type': 'dict',
'required': False,
'schema': {
'id': {'type': 'snowflake', 'required': False},
'size': {'type': 'list', 'required': False},
}
},
'assets': {
'type': 'dict',
'required': False,
'schema': {
'large_image': {'type': 'snowflake', 'required': False},
'large_text': {'type': 'string', 'required': False},
'small_image': {'type': 'snowflake', 'required': False},
'small_text': {'type': 'string', 'required': False},
}
},
'secrets': {
'type': 'dict',
'required': False,
'schema': {
'join': {'type': 'string', 'required': False},
'spectate': {'type': 'string', 'required': False},
'match': {'type': 'string', 'required': False},
}
},
'instance': {'type': 'boolean', 'required': False},
'flags': {'type': 'number', 'required': False},
}
GW_STATUS_UPDATE = {
'status': {'type': 'status_external', 'required': False,
'default': 'online'},
'activities': {
'type': 'list', 'required': False,
'schema': {'type': 'dict', 'schema': GW_ACTIVITY}
},
'afk': {'type': 'boolean', 'required': False},
'since': {'type': 'number', 'required': False, 'nullable': True},
'game': {
'type': 'dict',
'required': False,
'nullable': True,
'schema': GW_ACTIVITY,
},
}
INVITE = {
# max_age in seconds
# 0 for infinite
'max_age': {
'type': 'number',
'min': 0,
'max': 86400,
# a day
'default': 86400
},
# max invite uses
'max_uses': {
'type': 'number',
'min': 0,
# idk
'max': 1000,
# default infinite
'default': 0
},
'temporary': {'type': 'boolean', 'required': False, 'default': False},
'unique': {'type': 'boolean', 'required': False, 'default': True},
'validate': {'type': 'string', 'required': False, 'nullable': True} # discord client sends invite code there
}
USER_SETTINGS = {
'afk_timeout': {
'type': 'number', 'required': False, 'min': 0, 'max': 3000},
'animate_emoji': {'type': 'boolean', 'required': False},
'convert_emoticons': {'type': 'boolean', 'required': False},
'default_guilds_restricted': {'type': 'boolean', 'required': False},
'detect_platform_accounts': {'type': 'boolean', 'required': False},
'developer_mode': {'type': 'boolean', 'required': False},
'disable_games_tab': {'type': 'boolean', 'required': False},
'enable_tts_command': {'type': 'boolean', 'required': False},
'explicit_content_filter': {'type': 'explicit', 'required': False},
'friend_source': {
'type': 'dict',
'required': False,
'schema': {
'all': {'type': 'boolean', 'required': False},
'mutual_guilds': {'type': 'boolean', 'required': False},
'mutual_friends': {'type': 'boolean', 'required': False},
}
},
'guild_positions': {
'type': 'list',
'required': False,
'schema': {'type': 'snowflake'}
},
'restricted_guilds': {
'type': 'list',
'required': False,
'schema': {'type': 'snowflake'}
},
'gif_auto_play': {'type': 'boolean', 'required': False},
'inline_attachment_media': {'type': 'boolean', 'required': False},
'inline_embed_media': {'type': 'boolean', 'required': False},
'message_display_compact': {'type': 'boolean', 'required': False},
'render_embeds': {'type': 'boolean', 'required': False},
'render_reactions': {'type': 'boolean', 'required': False},
'show_current_game': {'type': 'boolean', 'required': False},
'timezone_offset': {'type': 'number', 'required': False},
'status': {'type': 'status_external', 'required': False},
'theme': {'type': 'theme', 'required': False}
}
RELATIONSHIP = {
'type': {
'type': 'rel_type',
'required': False,
'default': RelationshipType.FRIEND.value
}
}
CREATE_DM = {
'recipient_id': {
'type': 'snowflake',
'required': True
}
}
CREATE_GROUP_DM = {
'recipients': {
'type': 'list',
'required': True,
'schema': {'type': 'snowflake'}
},
}
GROUP_DM_UPDATE = {
'name': {
'type': 'guild_name',
'required': False
},
'icon': {'type': 'b64_icon', 'required': False, 'nullable': True},
}
SPECIFIC_FRIEND = {
'username': {'type': 'username'},
'discriminator': {'type': 'discriminator'}
}
GUILD_SETTINGS_CHAN_OVERRIDE = {
'type': 'dict',
'schema': {
'muted': {
'type': 'boolean', 'required': False},
'message_notifications': {
'type': 'msg_notifications',
'required': False,
}
}
}
GUILD_SETTINGS = {
'channel_overrides': {
'type': 'dict',
'valueschema': GUILD_SETTINGS_CHAN_OVERRIDE,
'keyschema': {'type': 'snowflake'},
'required': False,
},
'suppress_everyone': {
'type': 'boolean', 'required': False},
'muted': {
'type': 'boolean', 'required': False},
'mobile_push': {
'type': 'boolean', 'required': False},
'message_notifications': {
'type': 'msg_notifications',
'required': False,
}
}
GUILD_PRUNE = {
'days': {'type': 'number', 'coerce': int, 'min': 1}
}
NEW_EMOJI = {
'name': {
'type': 'string', 'minlength': 1, 'maxlength': 256, 'required': True},
'image': {'type': 'b64_icon', 'required': True},
'roles': {'type': 'list', 'schema': {'coerce': int}}
}
PATCH_EMOJI = {
'name': {
'type': 'string', 'minlength': 1, 'maxlength': 256, 'required': True},
'roles': {'type': 'list', 'schema': {'coerce': int}}
}
SEARCH_CHANNEL = {
'content': {'type': 'string', 'minlength': 1, 'required': True},
'include_nsfw': {'coerce': bool, 'default': False},
'offset': {'coerce': int, 'default': 0}
}
GET_MENTIONS = {
'limit': {'coerce': int, 'default': 25},
'roles': {'coerce': bool, 'default': True},
'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}
}
WEBHOOK_CREATE = {
'name': {
'type': 'string', 'minlength': 2, 'maxlength': 32,
'required': True
},
'avatar': {'type': 'b64_icon', 'required': False, 'nullable': False}
}
WEBHOOK_UPDATE = {
'name': {
'type': 'string', 'minlength': 2, 'maxlength': 32,
'required': False
},
# TODO: check if its b64_icon or string since the client
# could pass an icon hash instead.
'avatar': {'type': 'b64_icon', 'required': False, 'nullable': False},
'channel_id': {'coerce': int, 'required': False, 'nullable': False}
}
WEBHOOK_MESSAGE_CREATE = {
'content': {
'type': 'string',
'minlength': 0, 'maxlength': 2000, 'required': False
},
'tts': {'type': 'boolean', 'required': False},
'username': {
'type': 'string',
'minlength': 2, 'maxlength': 32, 'required': False
},
'avatar_url': {
'coerce': EmbedURL, 'required': False
},
'embeds': {
'type': 'list',
'required': False,
'schema': {'type': 'dict', 'schema': EMBED_OBJECT}
}
}