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:
commit
ac2f68f93f
@ -33,8 +33,10 @@ dependencies = [
|
||||
"PyJWT",
|
||||
"multiavatar",
|
||||
"gitpython",
|
||||
"uvloop",
|
||||
"humanize"
|
||||
'uvloop; platform_system != "Windows"',
|
||||
"humanize",
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -1,10 +1,6 @@
|
||||
import asyncio
|
||||
import asyncssh
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import pty
|
||||
|
||||
global _app
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -49,7 +49,7 @@
|
||||
})
|
||||
document.querySelector("upload-button").addEventListener("uploaded",function(e){
|
||||
e.detail.files.forEach((file)=>{
|
||||
app.rpc.sendMessage(channelUid,``)
|
||||
app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)
|
||||
})
|
||||
})
|
||||
textBox.addEventListener("paste", async (e) => {
|
||||
|
@ -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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user