From d5ad4bb96d275f6d72ddc54d12fcb2d974cc171f Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 22:08:37 -0300 Subject: [PATCH 01/16] migration.scripts: add 10_add_attachments_table - schema.sql: add attachments table --- .../scripts/10_add_attachments_table.sql | 21 +++++++++++++++++++ schema.sql | 15 +++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 manage/cmd/migration/scripts/10_add_attachments_table.sql diff --git a/manage/cmd/migration/scripts/10_add_attachments_table.sql b/manage/cmd/migration/scripts/10_add_attachments_table.sql new file mode 100644 index 0000000..cd2f634 --- /dev/null +++ b/manage/cmd/migration/scripts/10_add_attachments_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS attachments ( + id bigint PRIMARY KEY, + + filename text NOT NULL, + filesize integer, + + image boolean DEFAULT FALSE, + + -- only not null if image=true + height integer DEFAULT NULL, + width integer DEFAULT NULL +); + +-- recreate the attachments table since +-- its been always error'ing since some migrations ago. +CREATE TABLE IF NOT EXISTS message_attachments ( + message_id bigint REFERENCES messages (id), + attachment bigint REFERENCES attachments (id), + PRIMARY KEY (message_id, attachment) +); + diff --git a/schema.sql b/schema.sql index e3df14d..387a13f 100644 --- a/schema.sql +++ b/schema.sql @@ -52,6 +52,21 @@ CREATE TABLE IF NOT EXISTS instance_invites ( ); +-- main attachments table +CREATE TABLE IF NOT EXISTS attachments ( + id bigint PRIMARY KEY, + + filename text NOT NULL, + filesize integer, + + image boolean DEFAULT FALSE, + + -- only not null if image=true + height integer DEFAULT NULL, + width integer DEFAULT NULL, +); + + CREATE TABLE IF NOT EXISTS icons ( -- can be 'user', 'guild', 'emoji' scope text NOT NULL, From ec062f75a8ae1b678c33fb756fe77418e46089e3 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:01:07 -0300 Subject: [PATCH 02/16] channel.messages: add dummy implementation for attachment support --- litecord/blueprints/channel/messages.py | 99 ++++++++++++++++++++++++- litecord/schemas.py | 2 +- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index f1c8a5c..b089e5c 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -18,11 +18,12 @@ along with this program. If not, see . """ import re +import json +from PIL import Image from quart import Blueprint, request, current_app as app, jsonify from logbook import Logger - from litecord.blueprints.auth import token_check from litecord.blueprints.checks import channel_check, channel_perm_check from litecord.blueprints.dms import try_dm_state @@ -311,9 +312,95 @@ async def process_url_embed(config, storage, dispatcher, session, payload: dict) 'channel', channel_id, 'MESSAGE_UPDATE', update_payload) +async def _msg_input() -> tuple: + """Extract the json input and any file information + the client gave to us in the request. + + This only applies to create message route. + """ + form = await request.form + request_json = await request.get_json() or {} + + # NOTE: embed isn't set on form data + json_from_form = { + 'content': form.get('content', ''), + 'nonce': form.get('nonce', '0'), + 'tts': json.loads(form.get('tts', 'false')), + } + + json_from_form.update(request_json) + + files = await request.files + # we don't really care about the given fields on the files dict + return json_from_form, [v for k, v in files.items()] + + +def _check_content(payload: dict, files: list): + """Check if there is actually any content being sent to us.""" + has_content = bool(payload.get('content', '')) + has_embed = 'embed' in payload + has_files = len(files) > 0 + + has_total_content = has_content or has_embed or has_files + + if not has_total_content: + raise BadRequest('No content has been provided.') + + +async def _add_attachment(message_id: int, attachment_file) -> int: + """Add an attachment to a message. + + Parameters + ---------- + message_id: int + The ID of the message getting the attachment. + attachment_file: quart.FileStorage + quart FileStorage instance of the file. + """ + + attachment_id = get_snowflake() + + # understand file info + is_image = attachment_file.mimetype.startswith('image/') + + img_width, img_height = None, None + + # extract file size + # TODO: this is probably inneficient + file_size = attachment_file.stream.getbuffer().nbytes + + if is_image: + # open with pillow, extract image size + image = Image.open(attachment_file.stream) + img_width, img_height = image.size + image.close() + + # reset it to 0 for later usage + attachment_file.stream.seek(0) + + await app.db.execute( + """ + INSERT INTO attachments + (id, filename, filesize, image, height, width) + VALUES + ($1, $2, $3, $4, $5, $6) + """, + attachment_id, attachment_file.filename, file_size, + is_image, img_width, img_height) + + # add the newly created attachment to the message + await app.db.execute(""" + INSERT INTO message_attachments (message_id, attachment_id) + VALUES ($1, $2) + """, message_id, attachment_id) + + return attachment_id + + @bp.route('//messages', methods=['POST']) async def _create_message(channel_id): """Create a message.""" + user_id = await token_check() ctype, guild_id = await channel_check(user_id, channel_id) @@ -323,7 +410,11 @@ async def _create_message(channel_id): await channel_perm_check(user_id, channel_id, 'send_messages') actual_guild_id = guild_id - j = validate(await request.get_json(), MESSAGE_CREATE) + payload_json, files = await _msg_input() + j = validate(payload_json, MESSAGE_CREATE) + + print(payload_json, files) + _check_content(payload_json, files) # TODO: check connection to the gateway @@ -355,6 +446,10 @@ async def _create_message(channel_id): if 'embed' in j else []), }) + # for each file given, we add it as an attachment + for pre_attachment in files: + await _add_attachment(message_id, pre_attachment) + payload = await app.storage.get_message(message_id, user_id) if ctype == ChannelType.DM: diff --git a/litecord/schemas.py b/litecord/schemas.py index c631572..d21ef54 100644 --- a/litecord/schemas.py +++ b/litecord/schemas.py @@ -384,7 +384,7 @@ MEMBER_UPDATE = { MESSAGE_CREATE = { - 'content': {'type': 'string', 'minlength': 1, 'maxlength': 2000}, + 'content': {'type': 'string', 'minlength': 0, 'maxlength': 2000}, 'nonce': {'type': 'snowflake', 'required': False}, 'tts': {'type': 'boolean', 'required': False}, From 9ec8577b7f7dd654f6548553991eec71f7a28d35 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:12:43 -0300 Subject: [PATCH 03/16] blueprints: add dummy attachment blueprint - storage: add message attachment fetching (not working) --- litecord/blueprints/attachments.py | 29 +++++++++++++++++++++++ litecord/blueprints/channel/messages.py | 2 ++ litecord/storage.py | 31 ++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 litecord/blueprints/attachments.py diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py new file mode 100644 index 0000000..408843d --- /dev/null +++ b/litecord/blueprints/attachments.py @@ -0,0 +1,29 @@ +""" + +Litecord +Copyright (C) 2018 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +from quart import Blueprint + +bp = Blueprint(__name__) + +@bp.route('///.', + methods=['GET']) +async def _get_attachment(channel_id: int, message_id: int, + filename: str, ext: str): + # TODO: get the attachment id with given metadata + return diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index b089e5c..b3f9eee 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -388,6 +388,8 @@ async def _add_attachment(message_id: int, attachment_file) -> int: attachment_id, attachment_file.filename, file_size, is_image, img_width, img_height) + # TODO: save a file + # add the newly created attachment to the message await app.db.execute(""" INSERT INTO message_attachments (message_id, attachment_id) diff --git a/litecord/storage.py b/litecord/storage.py index 6b99dd3..04fe411 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -31,6 +31,7 @@ from litecord.blueprints.user.billing import PLAN_ID_TO_TYPE from litecord.types import timestamp_ from litecord.utils import pg_set_json +from litecord.embed.sanitizer import proxify log = Logger(__name__) @@ -636,6 +637,34 @@ class Storage: # they were defined in the first loop. return list(map(react_stats.get, emoji)) + async def get_attachments(self, message_id: int) -> List[Dict[str, Any]]: + """Get a list of attachment objects tied to the message.""" + attachment_ids = await self.db.fetch(""" + SELECT attachment_id + FROM message_attachments + WHERE message_id = $1 + """, message_id) + + res = [] + + for attachment_id in attachment_ids: + row = await self.db.fetchrow(""" + SELECT id::text, filename, filesize, image, height, width + FROM attachments + WHERE id = $1 + """, attachment_id) + + drow = dict(row) + + drow['size'] = drow['filesize'] + drow.pop('size') + + # TODO: url, proxy_url + + res.append(drow) + + return res + async def get_message(self, message_id: int, user_id=None) -> Dict: """Get a single message's payload.""" row = await self.fetchrow_with_json(""" @@ -703,7 +732,7 @@ class Storage: res.pop('author_id') # TODO: res['attachments'] - res['attachments'] = [] + res['attachments'] = await self.get_attachments(message_id) # TODO: res['member'] for partial member data # of the author From b6dd495121177803e7cede96c3025cb53339032f Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:13:48 -0300 Subject: [PATCH 04/16] run: register attachments bp --- litecord/blueprints/attachments.py | 3 ++- run.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index 408843d..ce78ac5 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -21,7 +21,8 @@ from quart import Blueprint bp = Blueprint(__name__) -@bp.route('///.', +@bp.route('/attachments' + '///.', methods=['GET']) async def _get_attachment(channel_id: int, message_id: int, filename: str, ext: str): diff --git a/run.py b/run.py index addd7f6..03e1a95 100644 --- a/run.py +++ b/run.py @@ -34,7 +34,8 @@ import config from litecord.blueprints import ( gateway, auth, users, guilds, channels, webhooks, science, - voice, invites, relationships, dms, icons, nodeinfo, static + voice, invites, relationships, dms, icons, nodeinfo, static, + attachments ) # those blueprints are separated from the "main" ones @@ -131,6 +132,7 @@ def set_blueprints(app_): fake_store: None, icons: -1, + attachments: -1, nodeinfo: -1, static: -1 } From 9ecc3d9e7e27842506d2ff8f5c52bfd5f054bea7 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:15:48 -0300 Subject: [PATCH 05/16] blueprints: fix register for attachments bp --- litecord/blueprints/__init__.py | 4 +++- litecord/blueprints/attachments.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/litecord/blueprints/__init__.py b/litecord/blueprints/__init__.py index 701bbd2..f43f9ca 100644 --- a/litecord/blueprints/__init__.py +++ b/litecord/blueprints/__init__.py @@ -31,6 +31,8 @@ from .dms import bp as dms from .icons import bp as icons from .nodeinfo import bp as nodeinfo from .static import bp as static +from .attachments import bp as attachments + __all__ = ['gateway', 'auth', 'users', 'guilds', 'channels', 'webhooks', 'science', 'voice', 'invites', 'relationships', - 'dms', 'icons', 'nodeinfo', 'static'] + 'dms', 'icons', 'nodeinfo', 'static', 'attachments'] diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index ce78ac5..07a5f7e 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -19,10 +19,10 @@ along with this program. If not, see . from quart import Blueprint -bp = Blueprint(__name__) +bp = Blueprint('attachments', __name__) @bp.route('/attachments' - '///.', + '///.', methods=['GET']) async def _get_attachment(channel_id: int, message_id: int, filename: str, ext: str): From e885bf859c9ecf73eb988b3badfb45e57c941fcb Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:21:11 -0300 Subject: [PATCH 06/16] migration.scripts: add attachments.{channel, message}_id - schema.sql: remove message_attachments table it is uneeded since we have the columns in attachments --- .../migration/scripts/10_add_attachments_table.sql | 12 +++--------- schema.sql | 11 +++++------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/manage/cmd/migration/scripts/10_add_attachments_table.sql b/manage/cmd/migration/scripts/10_add_attachments_table.sql index cd2f634..b1f979d 100644 --- a/manage/cmd/migration/scripts/10_add_attachments_table.sql +++ b/manage/cmd/migration/scripts/10_add_attachments_table.sql @@ -1,6 +1,9 @@ CREATE TABLE IF NOT EXISTS attachments ( id bigint PRIMARY KEY, + channel_id bigint REFERENCES channels (id), + message_id bigint REFERENCES messages (id), + filename text NOT NULL, filesize integer, @@ -10,12 +13,3 @@ CREATE TABLE IF NOT EXISTS attachments ( height integer DEFAULT NULL, width integer DEFAULT NULL ); - --- recreate the attachments table since --- its been always error'ing since some migrations ago. -CREATE TABLE IF NOT EXISTS message_attachments ( - message_id bigint REFERENCES messages (id), - attachment bigint REFERENCES attachments (id), - PRIMARY KEY (message_id, attachment) -); - diff --git a/schema.sql b/schema.sql index 387a13f..72d68e1 100644 --- a/schema.sql +++ b/schema.sql @@ -56,6 +56,11 @@ CREATE TABLE IF NOT EXISTS instance_invites ( CREATE TABLE IF NOT EXISTS attachments ( id bigint PRIMARY KEY, + -- keeping channel_id and message_id + -- make a way "better" attachment url. + channel_id bigint REFERENCES channels (id), + message_id bigint REFERENCES messages (id), + filename text NOT NULL, filesize integer, @@ -634,12 +639,6 @@ CREATE TABLE IF NOT EXISTS messages ( message_type int NOT NULL ); -CREATE TABLE IF NOT EXISTS message_attachments ( - message_id bigint REFERENCES messages (id), - attachment bigint REFERENCES attachments (id), - PRIMARY KEY (message_id, attachment) -); - CREATE TABLE IF NOT EXISTS message_reactions ( message_id bigint REFERENCES messages (id), user_id bigint REFERENCES users (id), From d8d975d10bff089837bc927f19ab9ba4ba2a0be4 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:22:38 -0300 Subject: [PATCH 07/16] add attachments folder with .gitkeep --- attachments/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 attachments/.gitkeep diff --git a/attachments/.gitkeep b/attachments/.gitkeep new file mode 100644 index 0000000..e69de29 From 57f6e530f5a9a64c83202ee403b274becaea38c4 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:23:08 -0300 Subject: [PATCH 08/16] gitignore: add attachments folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 062ffb8..e4064a2 100644 --- a/.gitignore +++ b/.gitignore @@ -105,5 +105,6 @@ venv.bak/ config.py images/* +attachments/* .DS_Store From 6c992588e984cbdf23fe3ce4f87cdac812940130 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:38:52 -0300 Subject: [PATCH 09/16] channel.messages: fix insertion into attachments table - channel.messages: add file write - images: make get_ext and get_mime public functions - storage: receive app instance instead of db --- litecord/blueprints/channel/messages.py | 31 +++++++++++++++---------- litecord/images.py | 12 +++++----- litecord/storage.py | 27 +++++++++++++++++---- run.py | 2 +- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index b3f9eee..acdec46 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -35,6 +35,7 @@ from litecord.utils import pg_set_json from litecord.embed.sanitizer import fill_embed, proxify, fetch_metadata from litecord.blueprints.channel.dm_checks import dm_pre_check +from litecord.images import get_ext log = Logger(__name__) @@ -347,7 +348,8 @@ def _check_content(payload: dict, files: list): raise BadRequest('No content has been provided.') -async def _add_attachment(message_id: int, attachment_file) -> int: +async def _add_attachment(message_id: int, channel_id: int, + attachment_file) -> int: """Add an attachment to a message. Parameters @@ -359,9 +361,11 @@ async def _add_attachment(message_id: int, attachment_file) -> int: """ attachment_id = get_snowflake() + filename = attachment_file.filename # understand file info - is_image = attachment_file.mimetype.startswith('image/') + mime = attachment_file.mimetype + is_image = mime.startswith('image/') img_width, img_height = None, None @@ -381,20 +385,23 @@ async def _add_attachment(message_id: int, attachment_file) -> int: await app.db.execute( """ INSERT INTO attachments - (id, filename, filesize, image, height, width) + (id, channel_id, message_id, + filename, filesize, + image, height, width) VALUES - ($1, $2, $3, $4, $5, $6) + ($1, $2, $3, $4, $5, $6, $7, $8) """, - attachment_id, attachment_file.filename, file_size, + attachment_id, channel_id, message_id, + filename, file_size, is_image, img_width, img_height) - # TODO: save a file + ext = filename.split('.')[-1] - # add the newly created attachment to the message - await app.db.execute(""" - INSERT INTO message_attachments (message_id, attachment_id) - VALUES ($1, $2) - """, message_id, attachment_id) + with open(f'attachments/{attachment_id}.{ext}') as attach_file: + attach_file.write(attachment_file.stream.read()) + + log.debug('written {} bytes for attachment id {}', + file_size, attachment_id) return attachment_id @@ -450,7 +457,7 @@ async def _create_message(channel_id): # for each file given, we add it as an attachment for pre_attachment in files: - await _add_attachment(message_id, pre_attachment) + await _add_attachment(message_id, channel_id, pre_attachment) payload = await app.storage.get_message(message_id, user_id) diff --git a/litecord/images.py b/litecord/images.py index 1cb9eb2..d0cf305 100644 --- a/litecord/images.py +++ b/litecord/images.py @@ -49,7 +49,7 @@ MIMES = { } -def _get_ext(mime: str) -> str: +def get_ext(mime: str) -> str: if mime in EXTENSIONS: return EXTENSIONS[mime] @@ -57,7 +57,7 @@ def _get_ext(mime: str) -> str: return extensions[0].strip('.') -def _get_mime(ext: str): +def get_mime(ext: str): if ext in MIMES: return MIMES[ext] @@ -74,7 +74,7 @@ class Icon: @property def as_path(self) -> str: """Return a filesystem path for the given icon.""" - ext = _get_ext(self.mime) + ext = get_ext(self.mime) return str(IMAGE_FOLDER / f'{self.key}_{self.icon_hash}.{ext}') @property @@ -83,7 +83,7 @@ class Icon: @property def extension(self) -> str: - return _get_ext(self.mime) + return get_ext(self.mime) class ImageError(Exception): @@ -201,7 +201,7 @@ class IconManager: self.storage = app.storage async def _convert_ext(self, icon: Icon, target: str): - target_mime = _get_mime(target) + target_mime = get_mime(target) log.info('converting from {} to {}', icon.mime, target_mime) target_path = IMAGE_FOLDER / f'{icon.key}_{icon.icon_hash}.{target}' @@ -328,7 +328,7 @@ class IconManager: data_fd = BytesIO(raw_data) # get an extension for the given data uri - extension = _get_ext(mime) + extension = get_ext(mime) if 'bsize' in kwargs and len(raw_data) > kwargs['bsize']: return _invalid(kwargs) diff --git a/litecord/storage.py b/litecord/storage.py index 04fe411..66629c2 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -31,7 +31,6 @@ from litecord.blueprints.user.billing import PLAN_ID_TO_TYPE from litecord.types import timestamp_ from litecord.utils import pg_set_json -from litecord.embed.sanitizer import proxify log = Logger(__name__) @@ -65,8 +64,9 @@ def _filter_recipients(recipients: List[Dict[str, Any]], user_id: int): class Storage: """Class for common SQL statements.""" - def __init__(self, db): - self.db = db + def __init__(self, app): + self.app = app + self.db = app.db self.presence = None async def fetchrow_with_json(self, query: str, *args): @@ -649,16 +649,35 @@ class Storage: for attachment_id in attachment_ids: row = await self.db.fetchrow(""" - SELECT id::text, filename, filesize, image, height, width + SELECT id::text, message_id, channel_id, mime + filename, filesize, image, height, width FROM attachments WHERE id = $1 """, attachment_id) drow = dict(row) + drow.pop('message_id') + drow.pop('channel_id') + drow.pop('mime') + drow['size'] = drow['filesize'] drow.pop('size') + # construct attachment url + proto = 'https' if self.app.config['IS_SSL'] else 'http' + main_url = self.app.config['MAIN_URL'] + + attachment_ext = get_ext(row['mime']) + + drow['url'] = (f'{proto}://{main_url}/attachments/' + f'{row["channel_id"]}/{row["message_id"]}/' + f'{row["filename"]}.{attachment_ext}') + + # NOTE: since the url comes from the instance itself + # i think proxy_url=url is valid. + drow['proxy_url'] = drow['url'] + # TODO: url, proxy_url res.append(drow) diff --git a/run.py b/run.py index 03e1a95..adb01f4 100644 --- a/run.py +++ b/run.py @@ -220,7 +220,7 @@ def init_app_managers(app): app.ratelimiter = RatelimitManager(app.config.get('_testing')) app.state_manager = StateManager() - app.storage = Storage(app.db) + app.storage = Storage(app) app.user_storage = UserStorage(app.storage) app.icons = IconManager(app) From 54650161b9d421498b9b34714ef20d93f3cfa7f2 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:39:56 -0300 Subject: [PATCH 10/16] storage: fix attachment.url --- litecord/storage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litecord/storage.py b/litecord/storage.py index 66629c2..595b23e 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -668,11 +668,9 @@ class Storage: proto = 'https' if self.app.config['IS_SSL'] else 'http' main_url = self.app.config['MAIN_URL'] - attachment_ext = get_ext(row['mime']) - drow['url'] = (f'{proto}://{main_url}/attachments/' f'{row["channel_id"]}/{row["message_id"]}/' - f'{row["filename"]}.{attachment_ext}') + f'{row["filename"]}') # NOTE: since the url comes from the instance itself # i think proxy_url=url is valid. From 501b82b4f035d4b19eed17616484c448cbb62316 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:43:14 -0300 Subject: [PATCH 11/16] storage: fix attachment ids fetch --- litecord/storage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litecord/storage.py b/litecord/storage.py index 595b23e..a6ecbee 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -640,8 +640,8 @@ class Storage: async def get_attachments(self, message_id: int) -> List[Dict[str, Any]]: """Get a list of attachment objects tied to the message.""" attachment_ids = await self.db.fetch(""" - SELECT attachment_id - FROM message_attachments + SELECT id + FROM attachments WHERE message_id = $1 """, message_id) From 7e17b6965e8b909f7ce2badfae772c27354b8cff Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:49:46 -0300 Subject: [PATCH 12/16] channel.message: fix issues related to file stream - storage: remove mime fetch --- litecord/blueprints/attachments.py | 2 +- litecord/blueprints/channel/messages.py | 6 ++++-- litecord/storage.py | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index 07a5f7e..220b9b1 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -27,4 +27,4 @@ bp = Blueprint('attachments', __name__) async def _get_attachment(channel_id: int, message_id: int, filename: str, ext: str): # TODO: get the attachment id with given metadata - return + return '', 204 diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index acdec46..ccdc90a 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -377,7 +377,9 @@ async def _add_attachment(message_id: int, channel_id: int, # open with pillow, extract image size image = Image.open(attachment_file.stream) img_width, img_height = image.size - image.close() + + # NOTE: DO NOT close the image, as closing the image will + # also close the stream. # reset it to 0 for later usage attachment_file.stream.seek(0) @@ -397,7 +399,7 @@ async def _add_attachment(message_id: int, channel_id: int, ext = filename.split('.')[-1] - with open(f'attachments/{attachment_id}.{ext}') as attach_file: + with open(f'attachments/{attachment_id}.{ext}', 'wb') as attach_file: attach_file.write(attachment_file.stream.read()) log.debug('written {} bytes for attachment id {}', diff --git a/litecord/storage.py b/litecord/storage.py index a6ecbee..fe4f317 100644 --- a/litecord/storage.py +++ b/litecord/storage.py @@ -645,11 +645,13 @@ class Storage: WHERE message_id = $1 """, message_id) + attachment_ids = [r['id'] for r in attachment_ids] + res = [] for attachment_id in attachment_ids: row = await self.db.fetchrow(""" - SELECT id::text, message_id, channel_id, mime + SELECT id::text, message_id, channel_id, filename, filesize, image, height, width FROM attachments WHERE id = $1 @@ -659,7 +661,6 @@ class Storage: drow.pop('message_id') drow.pop('channel_id') - drow.pop('mime') drow['size'] = drow['filesize'] drow.pop('size') From a8704f8176430e996627840c37de6c399a0fd633 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 8 Dec 2018 23:59:45 -0300 Subject: [PATCH 13/16] attachments: add attachment fetch impl - channel.messages: fix width/height switching --- litecord/blueprints/attachments.py | 22 +++++++++++++++++----- litecord/blueprints/channel/messages.py | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index 220b9b1..be05980 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -17,14 +17,26 @@ along with this program. If not, see . """ -from quart import Blueprint +from quart import Blueprint, send_file, current_app as app bp = Blueprint('attachments', __name__) @bp.route('/attachments' - '///.', + '///', methods=['GET']) async def _get_attachment(channel_id: int, message_id: int, - filename: str, ext: str): - # TODO: get the attachment id with given metadata - return '', 204 + filename: str): + attach_id = await app.db.fetchval(""" + SELECT id + FROM attachments + WHERE channel_id = $1 + AND message_id = $2 + AND filename = $3 + """, channel_id, message_id, filename) + + if attach_id is None: + return '', 404 + + ext = filename.split('.')[-1] + + return await send_file(f'./attachments/{attach_id}.{ext}') diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index ccdc90a..90da95e 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -389,7 +389,7 @@ async def _add_attachment(message_id: int, channel_id: int, INSERT INTO attachments (id, channel_id, message_id, filename, filesize, - image, height, width) + image, width, height) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, From d093e9c1ce013e888b51a07c1d1bf8ac9ef0996a Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 9 Dec 2018 00:10:06 -0300 Subject: [PATCH 14/16] channel.messages: remove unused import --- litecord/blueprints/channel/messages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litecord/blueprints/channel/messages.py b/litecord/blueprints/channel/messages.py index 90da95e..6d30c05 100644 --- a/litecord/blueprints/channel/messages.py +++ b/litecord/blueprints/channel/messages.py @@ -35,7 +35,6 @@ from litecord.utils import pg_set_json from litecord.embed.sanitizer import fill_embed, proxify, fetch_metadata from litecord.blueprints.channel.dm_checks import dm_pre_check -from litecord.images import get_ext log = Logger(__name__) From 35967cb714d4727b5140397329ec94063605c5cf Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 9 Dec 2018 01:26:56 -0300 Subject: [PATCH 15/16] attachments: add basic resizing Needs GIF support. --- litecord/blueprints/attachments.py | 55 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index be05980..cffe495 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -17,15 +17,46 @@ along with this program. If not, see . """ -from quart import Blueprint, send_file, current_app as app +from pathlib import Path + +from quart import Blueprint, send_file, current_app as app, request +from PIL import Image bp = Blueprint('attachments', __name__) +ATTACHMENTS = Path.cwd() / 'attachments' + + +async def _resize(image, attach_id: str, ext: str, + width: int, height: int) -> str: + """Resize an image.""" + # TODO: gif support + + # check if we have it on the folder + resized_path = ATTACHMENTS / f'{attach_id}_{width}_{height}.{ext}' + + # keep a str-fied instance since that is what + # we'll return. + resized_path_s = str(resized_path) + + if resized_path.exists(): + return resized_path_s + + # if we dont, we need to generate it off the + # given image instance. + + # NOTE: this is the same resize mode for icons. + resized = image.resize((width, height), resample=Image.LANCZOS) + resized.save(resized_path_s, format=ext) + + return resized_path_s + @bp.route('/attachments' '///', methods=['GET']) async def _get_attachment(channel_id: int, message_id: int, filename: str): + attach_id = await app.db.fetchval(""" SELECT id FROM attachments @@ -38,5 +69,25 @@ async def _get_attachment(channel_id: int, message_id: int, return '', 404 ext = filename.split('.')[-1] + filepath = f'./attachments/{attach_id}.{ext}' - return await send_file(f'./attachments/{attach_id}.{ext}') + image = Image.open(filepath) + im_width, im_height = image.size + + try: + width = int(request.args.get('width', 0)) or im_width + except ValueError: + return '', 400 + + try: + height = int(request.args.get('height', 0)) or im_height + except ValueError: + return '', 400 + + # if width and height are the same (happens if they weren't provided) + if width == im_width and height == im_height: + return await send_file(filepath) + + # resize image + new_filepath = await _resize(image, attach_id, ext, width, height) + return await send_file(new_filepath) From 1555a4717efcdb7e5dccfdb98ac17c0ff40ac1d3 Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 9 Dec 2018 01:51:58 -0300 Subject: [PATCH 16/16] attachments: add gif resize support - images: strip IconManager.resize_gif into its own function --- litecord/blueprints/attachments.py | 30 ++++++- litecord/images.py | 121 ++++++++++++++--------------- 2 files changed, 87 insertions(+), 64 deletions(-) diff --git a/litecord/blueprints/attachments.py b/litecord/blueprints/attachments.py index cffe495..2ece3e0 100644 --- a/litecord/blueprints/attachments.py +++ b/litecord/blueprints/attachments.py @@ -22,15 +22,34 @@ from pathlib import Path from quart import Blueprint, send_file, current_app as app, request from PIL import Image +from litecord.images import resize_gif + bp = Blueprint('attachments', __name__) ATTACHMENTS = Path.cwd() / 'attachments' -async def _resize(image, attach_id: str, ext: str, +async def _resize_gif(attach_id: int, resized_path: Path, + width: int, height: int) -> str: + """Resize a GIF attachment.""" + + # get original gif bytes + orig_path = ATTACHMENTS / f'{attach_id}.gif' + orig_bytes = orig_path.read_bytes() + + # give them and the target size to the + # image module's resize_gif + + _data_fd, raw_data = await resize_gif(orig_bytes, (width, height)) + + # write raw_data to the destination + resized_path.write_bytes(raw_data) + + return str(resized_path) + + +async def _resize(image, attach_id: int, ext: str, width: int, height: int) -> str: """Resize an image.""" - # TODO: gif support - # check if we have it on the folder resized_path = ATTACHMENTS / f'{attach_id}_{width}_{height}.{ext}' @@ -44,6 +63,11 @@ async def _resize(image, attach_id: str, ext: str, # if we dont, we need to generate it off the # given image instance. + # the process is different for gif files because we need + # gifsicle. doing it manually is too troublesome. + if ext == 'gif': + return await _resize_gif(attach_id, resized_path, width, height) + # NOTE: this is the same resize mode for icons. resized = image.resize((width, height), resample=Image.LANCZOS) resized.save(resized_path_s, format=ext) diff --git a/litecord/images.py b/litecord/images.py index d0cf305..d1395aa 100644 --- a/litecord/images.py +++ b/litecord/images.py @@ -194,6 +194,65 @@ def _try_unlink(path: str): pass +async def resize_gif(raw_data: bytes, target: tuple) -> tuple: + """Resize a GIF image.""" + # generate a temporary file to call gifsticle to and from. + input_fd, input_path = tempfile.mkstemp(suffix='.gif') + _, output_path = tempfile.mkstemp(suffix='.gif') + + input_handler = os.fdopen(input_fd, 'wb') + + # make sure its valid image data + data_fd = BytesIO(raw_data) + image = Image.open(data_fd) + image.close() + + log.info('resizing a GIF from {} to {}', + image.size, target) + + # insert image info on input_handler + # close it to make it ready for consumption by gifsicle + input_handler.write(raw_data) + input_handler.close() + + # call gifsicle under subprocess + log.debug('input: {}', input_path) + log.debug('output: {}', output_path) + + process = await asyncio.create_subprocess_shell( + f'gifsicle --resize {target[0]}x{target[1]} ' + f'{input_path} > {output_path}', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + # run it, etc. + out, err = await process.communicate() + + log.debug('out + err from gifsicle: {}', out + err) + + # write over an empty data_fd + data_fd = BytesIO() + output_handler = open(output_path, 'rb') + data_fd.write(output_handler.read()) + + # close unused handlers + output_handler.close() + + # delete the files we created with mkstemp + _try_unlink(input_path) + _try_unlink(output_path) + + # reseek, save to raw_data, reseek again. + # TODO: remove raw_data altogether as its inefficient + # to have two representations of the same bytes + data_fd.seek(0) + raw_data = data_fd.read() + data_fd.seek(0) + + return data_fd, raw_data + + class IconManager: """Main icon manager.""" def __init__(self, app): @@ -257,65 +316,6 @@ class IconManager: return await self.generic_get( 'guild', guild_id, icon_hash, **kwargs) - async def _resize_gif(self, scope: str, key, - raw_data: bytes, target: tuple) -> tuple: - """Resize a GIF image.""" - # generate a temporary file to call gifsticle to and from. - input_fd, input_path = tempfile.mkstemp(suffix='.gif') - _, output_path = tempfile.mkstemp(suffix='.gif') - - input_handler = os.fdopen(input_fd, 'wb') - - # make sure its valid image data - data_fd = BytesIO(raw_data) - image = Image.open(data_fd) - image.close() - - log.info('resizing a GIF from {} to {}', - image.size, target) - - # insert image info on input_handler - # close it to make it ready for consumption by gifsicle - input_handler.write(raw_data) - input_handler.close() - - # call gifsicle under subprocess - log.debug('input: {}', input_path) - log.debug('output: {}', output_path) - - process = await asyncio.create_subprocess_shell( - f'gifsicle --resize {target[0]}x{target[1]} ' - f'{input_path} > {output_path}', - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # run it, etc. - await process.communicate() - - # write over an empty data_fd - data_fd = BytesIO() - output_handler = open(output_path, 'rb') - data_fd.write(output_handler.read()) - - # close unused handlers - output_handler.close() - - # delete the files we created with mkstemp - _try_unlink(input_path) - _try_unlink(output_path) - - # reseek, save to raw_data, reseek again. - # TODO: remove raw_data altogether as its inefficient - # to have two representations of the same bytes - data_fd.seek(0) - raw_data = data_fd.read() - data_fd.seek(0) - - - return data_fd, raw_data - - async def put(self, scope: str, key: str, b64_data: str, **kwargs) -> Icon: """Insert an icon.""" @@ -336,8 +336,7 @@ class IconManager: # size management is different for gif files # as they're composed of multiple frames. if 'size' in kwargs and mime == 'image/gif': - data_fd, raw_data = await self._resize_gif( - scope, key, raw_data, kwargs['size']) + data_fd, raw_data = await resize_gif(raw_data, kwargs['size']) elif 'size' in kwargs: image = Image.open(data_fd)