mirror of https://gitlab.com/litecord/litecord.git
Merge branch 'guild-features' into 'master'
Guild features Closes #37 and #36 See merge request litecord/litecord!25
This commit is contained in:
commit
681dee4306
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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.
|
||||
|
||||
|
|
@ -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/<region>/server`
|
||||
### PUT `/voice/regions/<region>/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/<region>/deprecate`
|
||||
### PUT `/voice/regions/<region>/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/<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`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
||||
"""
|
||||
|
||||
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)}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
"""
|
||||
|
||||
from .voice import bp as voice
|
||||
from .features import bp as features
|
||||
from .guilds import bp as guilds
|
||||
|
||||
__all__ = ['voice']
|
||||
__all__ = ['voice', 'features', 'guilds']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('/<int:guild_id>/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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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('/<int:guild_id>', 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('/<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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
"""
|
||||
|
||||
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/<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')
|
||||
async def _get_default_user_avatar(discrim: int):
|
||||
pass
|
||||
|
|
@ -65,11 +60,8 @@ async def _get_default_user_avatar(discrim: int):
|
|||
|
||||
@bp.route('/avatars/<int: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)
|
||||
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/<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'])
|
||||
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/<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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -18,12 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
"""
|
||||
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
||||
"""
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 '{}';
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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
7
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():
|
||||
|
|
|
|||
29
schema.sql
29
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue