Add image conversion and resizing support in channel attachments
This commit is contained in:
		
							parent
							
								
									c48b84bf3a
								
							
						
					
					
						commit
						f156a153de
					
				@ -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