mirror of https://gitlab.com/litecord/litecord.git
guild: add implementations for emoji add/update/remove
All icons will be invalidated.
- images: change icon path model
- images: handle hashes being NULL, for emojis only needing
the key
- schemas: add NEW_EMOJI, PATCH_EMOJI
- migration.scripts: add 3_drop_constraints_icons_hash
- schema.sql: drop unique and not null from hash, change primary key in
icons
This commit is contained in:
parent
64af426035
commit
c70dd62306
|
|
@ -1,11 +1,21 @@
|
||||||
from quart import Blueprint, jsonify, current_app as app
|
from quart import Blueprint, jsonify, current_app as app, request
|
||||||
|
|
||||||
from litecord.auth import token_check
|
from litecord.auth import token_check
|
||||||
from litecord.blueprints.checks import guild_check
|
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
|
||||||
|
|
||||||
bp = Blueprint('guild.emoji', __name__)
|
bp = Blueprint('guild.emoji', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_emojis(guild_id):
|
||||||
|
"""Dispatch a Guild Emojis Update payload to a guild."""
|
||||||
|
await app.dispatcher.dispatch('guild', guild_id, 'GUILD_EMOJIS_UPDATE', {
|
||||||
|
'guild_id': str(guild_id),
|
||||||
|
'emojis': await app.storage.get_guild_emojis(guild_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:guild_id>/emojis', methods=['GET'])
|
@bp.route('/<int:guild_id>/emojis', methods=['GET'])
|
||||||
async def _get_guild_emoji(guild_id):
|
async def _get_guild_emoji(guild_id):
|
||||||
user_id = await token_check()
|
user_id = await token_check()
|
||||||
|
|
@ -22,3 +32,75 @@ async def _get_guild_emoji_one(guild_id, emoji_id):
|
||||||
return jsonify(
|
return jsonify(
|
||||||
await app.storage.get_emoji(emoji_id)
|
await app.storage.get_emoji(emoji_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:guild_id>/emojis', methods=['POST'])
|
||||||
|
async def _put_emoji(guild_id):
|
||||||
|
user_id = await token_check()
|
||||||
|
|
||||||
|
await guild_check(user_id, guild_id)
|
||||||
|
await guild_perm_check(user_id, guild_id, 'manage_emojis')
|
||||||
|
|
||||||
|
j = validate(await request.get_json(), NEW_EMOJI)
|
||||||
|
|
||||||
|
emoji_id = get_snowflake()
|
||||||
|
|
||||||
|
icon = await app.icons.put('emoji', emoji_id, None, j['image'])
|
||||||
|
|
||||||
|
await app.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO guild_emoji
|
||||||
|
(id, guild_id, uploader_id, name, image, animated)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
emoji_id, guild_id, user_id,
|
||||||
|
j['name'],
|
||||||
|
icon.icon_hash,
|
||||||
|
icon.mime == 'image/gif')
|
||||||
|
|
||||||
|
await _dispatch_emojis(guild_id)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
await app.storage.get_emoji(emoji_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:guild_id>/emojis/<int:emoji_id>', methods=['PATCH'])
|
||||||
|
async def _patch_emoji(guild_id, emoji_id):
|
||||||
|
user_id = await token_check()
|
||||||
|
|
||||||
|
await guild_check(user_id, guild_id)
|
||||||
|
await guild_perm_check(user_id, guild_id, 'manage_emojis')
|
||||||
|
|
||||||
|
j = validate(await request.get_json(), PATCH_EMOJI)
|
||||||
|
|
||||||
|
# TODO: check if it actually updated anything
|
||||||
|
await app.db.execute("""
|
||||||
|
UPDATE guild_emoji
|
||||||
|
SET name = $1
|
||||||
|
WHERE id = $2
|
||||||
|
""", j['name'], emoji_id)
|
||||||
|
|
||||||
|
await _dispatch_emojis(guild_id)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
await app.storage.get_emoji(emoji_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:guild_id>/emojis/<int:emoji_id>', methods=['DELETE'])
|
||||||
|
async def _del_emoji(guild_id, emoji_id):
|
||||||
|
user_id = await token_check()
|
||||||
|
|
||||||
|
await guild_check(user_id, guild_id)
|
||||||
|
await guild_perm_check(user_id, guild_id, 'manage_emojis')
|
||||||
|
|
||||||
|
# TODO: check if actually deleted
|
||||||
|
await app.db.execute("""
|
||||||
|
DELETE FROM guild_emoji
|
||||||
|
WHERE id = $2
|
||||||
|
""", emoji_id)
|
||||||
|
|
||||||
|
await _dispatch_emojis(guild_id)
|
||||||
|
return '', 204
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ def _get_mime(ext: str):
|
||||||
@dataclass
|
@dataclass
|
||||||
class Icon:
|
class Icon:
|
||||||
"""Main icon class"""
|
"""Main icon class"""
|
||||||
|
key: str
|
||||||
icon_hash: str
|
icon_hash: str
|
||||||
mime: str
|
mime: str
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ class Icon:
|
||||||
def as_path(self) -> str:
|
def as_path(self) -> str:
|
||||||
"""Return a filesystem path for the given icon."""
|
"""Return a filesystem path for the given icon."""
|
||||||
ext = _get_ext(self.mime)
|
ext = _get_ext(self.mime)
|
||||||
return str(IMAGE_FOLDER / f'{self.icon_hash}.{ext}')
|
return str(IMAGE_FOLDER / f'{self.key}_{self.icon_hash}.{ext}')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def as_pathlib(self) -> str:
|
def as_pathlib(self) -> str:
|
||||||
|
|
@ -147,40 +148,42 @@ class IconManager:
|
||||||
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)
|
||||||
|
|
||||||
target_path = IMAGE_FOLDER / f'{icon.icon_hash}.{target}'
|
target_path = IMAGE_FOLDER / f'{icon.key}_{icon.icon_hash}.{target}'
|
||||||
|
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
return Icon(icon.icon_hash, target_mime)
|
return Icon(icon.key, icon.icon_hash, target_mime)
|
||||||
|
|
||||||
image = Image.open(icon.as_path)
|
image = Image.open(icon.as_path)
|
||||||
target_fd = target_path.open('wb')
|
target_fd = target_path.open('wb')
|
||||||
image.save(target_fd, format=target)
|
image.save(target_fd, format=target)
|
||||||
target_fd.close()
|
target_fd.close()
|
||||||
|
|
||||||
return Icon(icon.icon_hash, target_mime)
|
return Icon(icon.key, icon.icon_hash, target_mime)
|
||||||
|
|
||||||
async def generic_get(self, scope, key, icon_hash, **kwargs) -> Icon:
|
async def generic_get(self, scope, key, icon_hash, **kwargs) -> Icon:
|
||||||
"""Get any icon."""
|
"""Get any icon."""
|
||||||
log.debug('GET {} {} {}', scope, key, icon_hash)
|
log.debug('GET {} {} {}', scope, key, icon_hash)
|
||||||
key = str(key)
|
key = str(key)
|
||||||
|
|
||||||
icon_row = await self.storage.db.fetchrow("""
|
hash_query = 'AND hash = $3' if key else ''
|
||||||
SELECT hash, mime
|
|
||||||
|
icon_row = await self.storage.db.fetchrow(f"""
|
||||||
|
SELECT key, hash, mime
|
||||||
FROM icons
|
FROM icons
|
||||||
WHERE scope = $1
|
WHERE scope = $1
|
||||||
AND key = $2
|
AND key = $2
|
||||||
AND hash = $3
|
{hash_query}
|
||||||
""", scope, key, icon_hash)
|
""", scope, key, icon_hash)
|
||||||
|
|
||||||
if not icon_row:
|
if not icon_row:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
icon = Icon(icon_row['hash'], icon_row['mime'])
|
icon = Icon(icon_row['key'], icon_row['hash'], icon_row['mime'])
|
||||||
|
|
||||||
if not icon.as_pathlib.exists():
|
if not icon.as_pathlib.exists():
|
||||||
await self.delete(icon)
|
await self.delete(icon)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if 'ext' in kwargs and kwargs['ext'] != icon.extension:
|
if 'ext' in kwargs and kwargs['ext'] != icon.extension:
|
||||||
return await self._convert_ext(icon, kwargs['ext'])
|
return await self._convert_ext(icon, kwargs['ext'])
|
||||||
|
|
||||||
|
|
@ -195,7 +198,7 @@ class IconManager:
|
||||||
b64_data: str, **kwargs) -> Icon:
|
b64_data: str, **kwargs) -> Icon:
|
||||||
"""Insert an icon."""
|
"""Insert an icon."""
|
||||||
if b64_data is None:
|
if b64_data is None:
|
||||||
return Icon(None, '')
|
return Icon(None, None, '')
|
||||||
|
|
||||||
mime, raw_data = parse_data_uri(b64_data)
|
mime, raw_data = parse_data_uri(b64_data)
|
||||||
data_fd = BytesIO(raw_data)
|
data_fd = BytesIO(raw_data)
|
||||||
|
|
@ -223,7 +226,10 @@ class IconManager:
|
||||||
data_fd.seek(0)
|
data_fd.seek(0)
|
||||||
|
|
||||||
# calculate sha256
|
# calculate sha256
|
||||||
icon_hash = await calculate_hash(data_fd)
|
# ignore icon hashes if we're talking about emoji
|
||||||
|
icon_hash = (await calculate_hash(data_fd)
|
||||||
|
if scope != 'emoji'
|
||||||
|
else None)
|
||||||
|
|
||||||
await self.storage.db.execute("""
|
await self.storage.db.execute("""
|
||||||
INSERT INTO icons (scope, key, hash, mime)
|
INSERT INTO icons (scope, key, hash, mime)
|
||||||
|
|
@ -231,14 +237,14 @@ class IconManager:
|
||||||
""", scope, str(key), icon_hash, mime)
|
""", scope, str(key), icon_hash, mime)
|
||||||
|
|
||||||
# write it off to fs
|
# write it off to fs
|
||||||
icon_path = IMAGE_FOLDER / f'{icon_hash}.{extension}'
|
icon_path = IMAGE_FOLDER / f'{key}_{icon_hash}.{extension}'
|
||||||
icon_path.write_bytes(raw_data)
|
icon_path.write_bytes(raw_data)
|
||||||
|
|
||||||
# copy from data_fd to icon_fd
|
# copy from data_fd to icon_fd
|
||||||
# with icon_path.open(mode='wb') as icon_fd:
|
# with icon_path.open(mode='wb') as icon_fd:
|
||||||
# icon_fd.write(data_fd.read())
|
# icon_fd.write(data_fd.read())
|
||||||
|
|
||||||
return Icon(icon_hash, mime)
|
return Icon(str(key), icon_hash, mime)
|
||||||
|
|
||||||
async def delete(self, icon: Icon):
|
async def delete(self, icon: Icon):
|
||||||
"""Delete an icon from the database and filesystem."""
|
"""Delete an icon from the database and filesystem."""
|
||||||
|
|
@ -274,7 +280,7 @@ class IconManager:
|
||||||
WHERE hash = $1
|
WHERE hash = $1
|
||||||
""", icon.icon_hash)
|
""", icon.icon_hash)
|
||||||
|
|
||||||
paths = IMAGE_FOLDER.glob(f'{icon.icon_hash}.*')
|
paths = IMAGE_FOLDER.glob(f'{icon.key}_{icon.icon_hash}.*')
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -566,3 +566,16 @@ GUILD_SETTINGS = {
|
||||||
GUILD_PRUNE = {
|
GUILD_PRUNE = {
|
||||||
'days': {'type': 'number', 'coerce': int, 'min': 1}
|
'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}}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- drop main primary key
|
||||||
|
-- since hash can now be nullable
|
||||||
|
ALTER TABLE icons DROP CONSTRAINT "icons_pkey";
|
||||||
|
|
||||||
|
-- remove not null from hash column
|
||||||
|
ALTER TABLE icons ALTER COLUMN hash DROP NOT NULL;
|
||||||
|
|
||||||
|
-- add new primary key, without hash
|
||||||
|
ALTER TABLE icons ADD CONSTRAINT icons_pkey PRIMARY KEY (scope, key);
|
||||||
|
|
@ -49,11 +49,11 @@ CREATE TABLE IF NOT EXISTS icons (
|
||||||
key text,
|
key text,
|
||||||
|
|
||||||
-- sha256 of the icon
|
-- sha256 of the icon
|
||||||
hash text UNIQUE NOT NULL,
|
hash text,
|
||||||
|
|
||||||
-- icon mime
|
-- icon mime
|
||||||
mime text NOT NULL,
|
mime text NOT NULL,
|
||||||
PRIMARY KEY (scope, hash, mime)
|
PRIMARY KEY (scope, key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue