diff --git a/src/snek/app.py b/src/snek/app.py index 362d519..0a6b018 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -53,6 +53,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView from snek.view.upload import UploadView from snek.view.user import UserView from snek.view.web import WebView +from snek.view.channel import ChannelAttachmentView from snek.webdav import WebdavApplication from snek.sgit import GitApplication @@ -175,6 +176,8 @@ class Application(BaseApplication): self.router.add_get("/http-get", self.handle_http_get) self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/rpc.ws", RPCView) + self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView) + self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView) self.router.add_view("/channel/{channel}.html", WebView) self.router.add_view("/threads.html", ThreadsView) self.router.add_view("/terminal.ws", TerminalSocketView) diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py index ab7904f..5428f10 100644 --- a/src/snek/mapper/__init__.py +++ b/src/snek/mapper/__init__.py @@ -9,6 +9,7 @@ from snek.mapper.notification import NotificationMapper from snek.mapper.user import UserMapper from snek.mapper.user_property import UserPropertyMapper from snek.mapper.repository import RepositoryMapper +from snek.mapper.channel_attachment import ChannelAttachmentMapper from snek.system.object import Object @@ -25,6 +26,7 @@ def get_mappers(app=None): "drive": DriveMapper(app=app), "user_property": UserPropertyMapper(app=app), "repository": RepositoryMapper(app=app), + "channel_attachment": ChannelAttachmentMapper(app=app), } ) diff --git a/src/snek/mapper/channel_attachment.py b/src/snek/mapper/channel_attachment.py new file mode 100644 index 0000000..0d6e404 --- /dev/null +++ b/src/snek/mapper/channel_attachment.py @@ -0,0 +1,7 @@ +from snek.model.channel_attachment import ChannelAttachmentModel +from snek.system.mapper import BaseMapper + + +class ChannelAttachmentMapper(BaseMapper): + table_name = "channel_attachment" + model_class = ChannelAttachmentModel diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py index 6399c89..17832c6 100644 --- a/src/snek/model/__init__.py +++ b/src/snek/model/__init__.py @@ -11,6 +11,7 @@ from snek.model.notification import NotificationModel from snek.model.user import UserModel from snek.model.user_property import UserPropertyModel from snek.model.repository import RepositoryModel +from snek.model.channel_attachment import ChannelAttachmentModel from snek.system.object import Object @@ -27,6 +28,7 @@ def get_models(): "notification": NotificationModel, "user_property": UserPropertyModel, "repository": RepositoryModel, + "channel_attachment": ChannelAttachmentModel, } ) diff --git a/src/snek/model/channel_attachment.py b/src/snek/model/channel_attachment.py new file mode 100644 index 0000000..9add1b8 --- /dev/null +++ b/src/snek/model/channel_attachment.py @@ -0,0 +1,16 @@ +from snek.system.model import BaseModel +from snek.system.model import BaseModel, ModelField + + +class ChannelAttachmentModel(BaseModel): + + name = ModelField(name="name", required=True, kind=str) + channel_uid = ModelField(name="channel_uid", required=True, kind=str) + path = ModelField(name="path", required=True, kind=str) + size = ModelField(name="size", required=False, kind=int) + user_uid = ModelField(name="user_uid", required=True, kind=str) + mime_type = ModelField(name="type", required=True, kind=str) + relative_url = ModelField(name="relative_url", required=True, kind=str) + resource_type = ModelField(name="resource_type", required=True, kind=str,value="file") + + diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py index a81b9e7..dae9e09 100644 --- a/src/snek/service/__init__.py +++ b/src/snek/service/__init__.py @@ -12,6 +12,7 @@ from snek.service.user import UserService from snek.service.user_property import UserPropertyService from snek.service.util import UtilService from snek.service.repository import RepositoryService +from snek.service.channel_attachment import ChannelAttachmentService from snek.system.object import Object from snek.service.db import DBService @@ -32,6 +33,7 @@ def get_services(app): "user_property": UserPropertyService(app=app), "repository": RepositoryService(app=app), "db": DBService(app=app), + "channel_attachment": ChannelAttachmentService(app=app), } ) diff --git a/src/snek/service/channel.py b/src/snek/service/channel.py index b90e66f..f2288cb 100644 --- a/src/snek/service/channel.py +++ b/src/snek/service/channel.py @@ -3,10 +3,19 @@ from datetime import datetime from snek.system.model import now from snek.system.service import BaseService +import pathlib class ChannelService(BaseService): mapper_name = "channel" + async def get_attachment_folder(self, channel_uid,ensure=False): + path = pathlib.Path(f"./drive/{channel_uid}/attachments") + if ensure: + path.mkdir( + parents=True, exist_ok=True + ) + return path + async def get(self, uid=None, **kwargs): if uid: kwargs["uid"] = uid diff --git a/src/snek/service/channel_attachment.py b/src/snek/service/channel_attachment.py new file mode 100644 index 0000000..d225a7b --- /dev/null +++ b/src/snek/service/channel_attachment.py @@ -0,0 +1,25 @@ +from snek.system.service import BaseService +import urllib.parse +import pathlib +import mimetypes +import uuid + +class ChannelAttachmentService(BaseService): + mapper_name="channel_attachment" + + async def create_file(self, channel_uid, user_uid, name): + attachment = await self.new() + attachment["channel_uid"] = channel_uid + attachment['user_uid'] = user_uid + attachment["name"] = name + attachment["mime_type"] = mimetypes.guess_type(name)[0] + attachment['resource_type'] = "file" + real_file_name = f"{attachment['uid']}-{name}" + attachment["relative_url"] = urllib.parse.quote(f"{attachment['uid']}/{name}") + attachment_folder = await self.services.channel.get_attachment_folder(channel_uid) + attachment_path = attachment_folder.joinpath(real_file_name) + attachment["path"] = str(attachment_path) + if await self.save(attachment): + return attachment + raise Exception(f"Failed to create channel attachment: {attachment.errors}.") + diff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js index 06563c9..4fc2994 100644 --- a/src/snek/static/upload-button.js +++ b/src/snek/static/upload-button.js @@ -21,13 +21,13 @@ class UploadButtonElement extends HTMLElement { const files = fileInput.files; const formData = new FormData(); - formData.append('channel_uid', this.channelUid); for (let i = 0; i < files.length; i++) { formData.append('files[]', files[i]); } - const request = new XMLHttpRequest(); - request.open('POST', '/drive.bin', true); + + request.responseType = 'json'; + request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true); request.upload.onprogress = function (event) { if (event.lengthComputable) { @@ -35,9 +35,10 @@ class UploadButtonElement extends HTMLElement { uploadButton.innerText = `${Math.round(percentComplete)}%`; } }; - + const me = this request.onload = function () { if (request.status === 200) { + me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response })); uploadButton.innerHTML = '📤'; } else { alert('Upload failed'); diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 5a5cc2c..0810587 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -47,6 +47,11 @@ document.querySelector("upload-button").addEventListener("upload",function(e){ getInputField().focus(); }) + document.querySelector("upload-button").addEventListener("uploaded",function(e){ + e.detail.files.forEach((file)=>{ + app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`) + }) + }) textBox.addEventListener("paste", async (e) => { try { const clipboardItems = await navigator.clipboard.read(); diff --git a/src/snek/view/channel.py b/src/snek/view/channel.py new file mode 100644 index 0000000..93ad412 --- /dev/null +++ b/src/snek/view/channel.py @@ -0,0 +1,53 @@ +from snek.system.view import BaseView +import aiofiles +from aiohttp import web +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"]}"' + ) + 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) + + if not channel_member: + return web.HTTPNotFound() + + reader = await self.request.multipart() + 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 + ) + + attachments.append(attachment) + 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) + + return web.json_response( + { + "message": "Files uploaded successfully", + "files": [attachment.record for attachment in attachments], + "channel_uid": channel_uid, + } + )