Merge pull request 'Add image conversion and resizing support in channel attachments' () from BordedDev/snek:feat/image-conversion-resizing into main

Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
This commit is contained in:
retoor 2025-05-13 18:19:57 +02:00
commit ac2f68f93f
7 changed files with 149 additions and 42 deletions

View File

@ -33,8 +33,10 @@ dependencies = [
"PyJWT",
"multiavatar",
"gitpython",
"uvloop",
"humanize"
'uvloop; platform_system != "Windows"',
"humanize",
"Pillow",
"pillow-heif",
]
[tool.setuptools.packages.find]

View File

@ -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
)

View File

@ -1,10 +1,6 @@
import asyncio
import asyncssh
import logging
import os
from pathlib import Path
import sys
import pty
global _app

View File

@ -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 = '<picture><srcset srcset="' + src +'" /><img src="' + src + '" alt="' + alt + '"'
# if title:
# s += ' title="' + safe_entity(title) + '"'
# return s + ' /></picture>'
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)

View File

@ -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'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
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'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
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'''
<picture>
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
<source srcset="{element.attrs["src"]}?format=webp" type="image/webp" />
<img src="{element.attrs["src"]}" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
</picture>'''
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

View File

@ -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) => {

View File

@ -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)