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",
|
"PyJWT",
|
||||||
"multiavatar",
|
"multiavatar",
|
||||||
"gitpython",
|
"gitpython",
|
||||||
"uvloop",
|
'uvloop; platform_system != "Windows"',
|
||||||
"humanize"
|
"humanize",
|
||||||
|
"Pillow",
|
||||||
|
"pillow-heif",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
@ -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):
|
||||||
|
try:
|
||||||
|
import uvloop
|
||||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,7 +106,10 @@ 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]
|
||||||
|
|
||||||
|
if file_mime and file_mime.startswith("image/") or any(
|
||||||
|
ext in element.attrs["href"].lower() for ext in [
|
||||||
".png",
|
".png",
|
||||||
".jpg",
|
".jpg",
|
||||||
".jpeg",
|
".jpeg",
|
||||||
@ -116,12 +120,26 @@ def embed_image(text):
|
|||||||
".tiff",
|
".tiff",
|
||||||
".ico",
|
".ico",
|
||||||
".heif",
|
".heif",
|
||||||
]:
|
".heic",
|
||||||
if extension in element.attrs["href"].lower():
|
]
|
||||||
|
):
|
||||||
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
|
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
|
||||||
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
|
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
|
||||||
return str(soup)
|
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)
|
||||||
|
|
||||||
|
|
||||||
def embed_media(text):
|
def embed_media(text):
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,``)
|
app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
textBox.addEventListener("paste", async (e) => {
|
textBox.addEventListener("paste", async (e) => {
|
||||||
|
@ -1,26 +1,99 @@
|
|||||||
|
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(
|
||||||
|
relative_url=relative_path
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = web.FileResponse(channel_attachment["path"])
|
||||||
response.headers["Cache-Control"] = f"public, max-age={1337*420}"
|
response.headers["Cache-Control"] = f"public, max-age={1337 * 420}"
|
||||||
response.headers["Content-Disposition"] = (
|
response.headers["Content-Disposition"] = (
|
||||||
f'attachment; filename="{channel_attachment["name"]}"'
|
f'attachment; filename="{channel_attachment["name"]}"'
|
||||||
)
|
)
|
||||||
return response
|
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)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user