From f156a153de1b2f89b99cf0490eb18bf27a611fe1 Mon Sep 17 00:00:00 2001 From: BordedDev <> Date: Mon, 12 May 2025 01:47:54 +0200 Subject: [PATCH] Add image conversion and resizing support in channel attachments --- pyproject.toml | 6 +- src/snek/__main__.py | 8 ++- src/snek/sssh.py | 4 -- src/snek/system/markdown.py | 15 ++++- src/snek/system/template.py | 50 ++++++++++++----- src/snek/templates/web.html | 2 +- src/snek/view/channel.py | 106 ++++++++++++++++++++++++++++++------ 7 files changed, 149 insertions(+), 42 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc84391..6cb0070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,10 @@ dependencies = [ "PyJWT", "multiavatar", "gitpython", - "uvloop", - "humanize" + 'uvloop; platform_system != "Windows"', + "humanize", + "Pillow", + "pillow-heif", ] [tool.setuptools.packages.find] diff --git a/src/snek/__main__.py b/src/snek/__main__.py index 35e56e3..0f06499 100644 --- a/src/snek/__main__.py +++ b/src/snek/__main__.py @@ -1,5 +1,4 @@ import click -import uvloop from aiohttp import web import asyncio from snek.app import Application @@ -14,7 +13,12 @@ def cli(): @click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on') @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application') def serve(port, host, db_path): - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + try: + import uvloop + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + except ImportError: + print("uvloop not installed, using default event loop.") + web.run_app( Application(db_path=f"sqlite:///{db_path}"), port=port, host=host ) diff --git a/src/snek/sssh.py b/src/snek/sssh.py index 0106a17..848b2f9 100644 --- a/src/snek/sssh.py +++ b/src/snek/sssh.py @@ -1,10 +1,6 @@ -import asyncio import asyncssh import logging -import os from pathlib import Path -import sys -import pty global _app diff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py index 82a222e..b708666 100644 --- a/src/snek/system/markdown.py +++ b/src/snek/system/markdown.py @@ -4,6 +4,9 @@ from types import SimpleNamespace from app.cache import time_cache_async from mistune import HTMLRenderer, Markdown +from mistune.plugins.formatting import strikethrough +from mistune.plugins.spoiler import spoiler +from mistune.plugins.url import url from pygments import highlight from pygments.formatters import html from pygments.lexers import get_lexer_by_name @@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer): _allow_harmful_protocols = True def __init__(self, app, template): + super().__init__(False, True) + self.template = template self.app = app @@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer): markdown = Markdown(renderer=renderer) return markdown(markdown_string) + # def image(self, text: str, url: str, title: Optional[str] = None) -> str: + # src = self.safe_url(url) + # alt = escape(striptags(text)) + # s = '' + alt + '' + def render_markdown_sync(app, markdown_string): renderer = MarkdownRenderer(app, None) - markdown = Markdown(renderer=renderer) + markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler]) return markdown(markdown_string) diff --git a/src/snek/system/template.py b/src/snek/system/template.py index d4b6819..93fd33c 100644 --- a/src/snek/system/template.py +++ b/src/snek/system/template.py @@ -1,6 +1,7 @@ import re from types import SimpleNamespace +import mimetypes import emoji from bs4 import BeautifulSoup from jinja2 import TemplateSyntaxError, nodes @@ -105,21 +106,38 @@ def embed_youtube(text): def embed_image(text): soup = BeautifulSoup(text, "html.parser") for element in soup.find_all("a"): - for extension in [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".svg", - ".bmp", - ".tiff", - ".ico", - ".heif", - ]: - if extension in element.attrs["href"].lower(): - embed_template = f'{element.attrs[' - element.replace_with(BeautifulSoup(embed_template, "html.parser")) + file_mime = mimetypes.guess_type(element.attrs["href"])[0] + + if file_mime and file_mime.startswith("image/") or any( + ext in element.attrs["href"].lower() for ext in [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".tiff", + ".ico", + ".heif", + ".heic", + ] + ): + embed_template = f'{element.attrs[' + element.replace_with(BeautifulSoup(embed_template, "html.parser")) + return str(soup) + +def enrich_image_rendering(text): + soup = BeautifulSoup(text, "html.parser") + for element in soup.find_all("img"): + if element.attrs["src"].startswith("/" ): + picture_template = f''' + + + + {element.attrs[ + ''' + element.replace_with(BeautifulSoup(picture_template, "html.parser")) return str(soup) @@ -205,6 +223,8 @@ class LinkifyExtension(Extension): result = embed_media(result) result = embed_image(result) result = embed_youtube(result) + + result = enrich_image_rendering(result) return result diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 0810587..1046d2e 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -49,7 +49,7 @@ }) document.querySelector("upload-button").addEventListener("uploaded",function(e){ e.detail.files.forEach((file)=>{ - app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`) + app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`) }) }) textBox.addEventListener("paste", async (e) => { diff --git a/src/snek/view/channel.py b/src/snek/view/channel.py index 93ad412..86ed7a0 100644 --- a/src/snek/view/channel.py +++ b/src/snek/view/channel.py @@ -1,27 +1,100 @@ +import asyncio +import mimetypes + +from PIL import Image +import pillow_heif.HeifImagePlugin + from snek.system.view import BaseView -import aiofiles +import aiofiles from aiohttp import web -import pathlib +import pathlib + class ChannelAttachmentView(BaseView): - async def get(self): relative_path = self.request.match_info.get("relative_url") - channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path) - response = web.FileResponse(channel_attachment["path"]) - response.headers["Cache-Control"] = f"public, max-age={1337*420}" - response.headers["Content-Disposition"] = ( - f'attachment; filename="{channel_attachment["name"]}"' + channel_attachment = await self.services.channel_attachment.get( + relative_url=relative_path ) - return response + + current_format = mimetypes.guess_type(channel_attachment["path"])[0] + + format = self.request.query.get("format") + width = self.request.query.get("width") + height = self.request.query.get("height") + + if any([format, width, height]) and current_format.startswith("image/"): + with Image.open(channel_attachment["path"]) as image: + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Cache-Control": f"public, max-age={1337 * 420}", + "Content-Type": f"image/{format}" if format else current_format, + "Content-Disposition": f'attachment; filename="{channel_attachment["name"]}"', + }, + ) + + if width or height: + width = min(int(width), image.size[0]) if width else None + height = min(int(height), image.size[1]) if height else None + + if width and height: + smallest_ratio = max( + image.size[0] / int(width), image.size[1] / int(height) + ) + image.thumbnail( + ( + int(image.size[0] / smallest_ratio), + int(image.size[1] / smallest_ratio), + ) + ) + elif width: + image.thumbnail( + ( + int(width), + int(image.size[1] * image.size[0] / int(width)), + ) + ) + elif height: + image.thumbnail( + ( + int(image.size[0] * image.size[1] / int(height)), + int(height), + ) + ) + + await response.prepare(self.request) + + # response.write is async but image.save is not + naughty_steal = response.write + loop = asyncio.get_event_loop() + + def sync_writer(*args, **kwargs): + return loop.run_until_complete(naughty_steal(*args, **kwargs)) + + setattr(response, "write", sync_writer) + + image.save(response, format=self.request.query["format"]) + + setattr(response, "write", naughty_steal) + return response + else: + response = web.FileResponse(channel_attachment["path"]) + response.headers["Cache-Control"] = f"public, max-age={1337 * 420}" + response.headers["Content-Disposition"] = ( + f'attachment; filename="{channel_attachment["name"]}"' + ) + return response async def post(self): - channel_uid = self.request.match_info.get("channel_uid") user_uid = self.request.session.get("uid") - channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False) - + channel_member = await self.services.channel_member.get( + user_uid=user_uid, channel_uid=channel_uid, deleted_at=None, is_banned=False + ) + if not channel_member: return web.HTTPNotFound() @@ -29,18 +102,17 @@ class ChannelAttachmentView(BaseView): attachments = [] while field := await reader.next(): - filename = field.filename if not filename: continue attachment = await self.services.channel_attachment.create_file( - channel_uid=channel_uid, name=filename,user_uid=user_uid + channel_uid=channel_uid, name=filename, user_uid=user_uid ) - + attachments.append(attachment) - pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True) - async with aiofiles.open(attachment['path'], "wb") as f: + pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(attachment["path"], "wb") as f: while chunk := await field.read_chunk(): await f.write(chunk) -- 2.45.2