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

Reviewed-on: #36
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", "PyJWT",
"multiavatar", "multiavatar",
"gitpython", "gitpython",
"uvloop", 'uvloop; platform_system != "Windows"',
"humanize" "humanize",
"Pillow",
"pillow-heif",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@ -1,5 +1,4 @@
import click import click
import uvloop
from aiohttp import web from aiohttp import web
import asyncio import asyncio
from snek.app import Application 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('--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') @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
def serve(port, host, db_path): 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( web.run_app(
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
) )

View File

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

View File

@ -4,6 +4,9 @@ from types import SimpleNamespace
from app.cache import time_cache_async from app.cache import time_cache_async
from mistune import HTMLRenderer, Markdown 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 import highlight
from pygments.formatters import html from pygments.formatters import html
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):
_allow_harmful_protocols = True _allow_harmful_protocols = True
def __init__(self, app, template): def __init__(self, app, template):
super().__init__(False, True)
self.template = template self.template = template
self.app = app self.app = app
@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):
markdown = Markdown(renderer=renderer) markdown = Markdown(renderer=renderer)
return markdown(markdown_string) 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): def render_markdown_sync(app, markdown_string):
renderer = MarkdownRenderer(app, None) renderer = MarkdownRenderer(app, None)
markdown = Markdown(renderer=renderer) markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
return markdown(markdown_string) return markdown(markdown_string)

View File

@ -1,6 +1,7 @@
import re import re
from types import SimpleNamespace from types import SimpleNamespace
import mimetypes
import emoji import emoji
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes from jinja2 import TemplateSyntaxError, nodes
@ -105,21 +106,38 @@ def embed_youtube(text):
def embed_image(text): def embed_image(text):
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"): for element in soup.find_all("a"):
for extension in [ file_mime = mimetypes.guess_type(element.attrs["href"])[0]
".png",
".jpg", if file_mime and file_mime.startswith("image/") or any(
".jpeg", ext in element.attrs["href"].lower() for ext in [
".gif", ".png",
".webp", ".jpg",
".svg", ".jpeg",
".bmp", ".gif",
".tiff", ".webp",
".ico", ".svg",
".heif", ".bmp",
]: ".tiff",
if extension in element.attrs["href"].lower(): ".ico",
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />' ".heif",
element.replace_with(BeautifulSoup(embed_template, "html.parser")) ".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) return str(soup)
@ -205,6 +223,8 @@ class LinkifyExtension(Extension):
result = embed_media(result) result = embed_media(result)
result = embed_image(result) result = embed_image(result)
result = embed_youtube(result) result = embed_youtube(result)
result = enrich_image_rendering(result)
return result return result

View File

@ -49,7 +49,7 @@
}) })
document.querySelector("upload-button").addEventListener("uploaded",function(e){ document.querySelector("upload-button").addEventListener("uploaded",function(e){
e.detail.files.forEach((file)=>{ 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) => { 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 from snek.system.view import BaseView
import aiofiles import aiofiles
from aiohttp import web from aiohttp import web
import pathlib import pathlib
class ChannelAttachmentView(BaseView): class ChannelAttachmentView(BaseView):
async def get(self): async def get(self):
relative_path = self.request.match_info.get("relative_url") relative_path = self.request.match_info.get("relative_url")
channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path) channel_attachment = await self.services.channel_attachment.get(
response = web.FileResponse(channel_attachment["path"]) relative_url=relative_path
response.headers["Cache-Control"] = f"public, max-age={1337*420}"
response.headers["Content-Disposition"] = (
f'attachment; filename="{channel_attachment["name"]}"'
) )
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): async def post(self):
channel_uid = self.request.match_info.get("channel_uid") channel_uid = self.request.match_info.get("channel_uid")
user_uid = self.request.session.get("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: if not channel_member:
return web.HTTPNotFound() return web.HTTPNotFound()
@ -29,18 +102,17 @@ class ChannelAttachmentView(BaseView):
attachments = [] attachments = []
while field := await reader.next(): while field := await reader.next():
filename = field.filename filename = field.filename
if not filename: if not filename:
continue continue
attachment = await self.services.channel_attachment.create_file( 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) attachments.append(attachment)
pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True) pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(attachment['path'], "wb") as f: async with aiofiles.open(attachment["path"], "wb") as f:
while chunk := await field.read_chunk(): while chunk := await field.read_chunk():
await f.write(chunk) await f.write(chunk)