From 7e5e4fd29b9656aa71b87cab9f5d73a7616db37c Mon Sep 17 00:00:00 2001 From: jedi Date: Thu, 23 Nov 2023 23:17:20 +0100 Subject: [PATCH] wip --- core/core/globals.py | 29 +++++++ core/core/settings.py | 1 + core/core/urls.py | 2 +- core/mail/__init__.py | 0 core/mail/migrations/__init__.py | 0 core/mail/models.py | 15 ++++ core/mail/protocol.py | 62 ++++++++++++++ core/mail/socket.py | 31 +++++++ core/mail/tests/__init__.py | 0 core/notify_sessions/models.py | 25 ++++++ core/notify_sessions/tests/__init__.py | 0 .../tests/test_notify_socket.py | 57 +++++++++++++ core/requirements.prod.txt | 14 --- .../ansible/playbooks/templates/nginx.conf.j2 | 2 +- web/src/App.vue | 85 +++++++++++++------ web/src/components/Navbar.vue | 6 +- web/src/router.js | 2 + web/src/store/index.js | 50 +++++------ web/src/views/Debug.vue | 39 +++++++++ 19 files changed, 352 insertions(+), 68 deletions(-) create mode 100644 core/core/globals.py create mode 100644 core/mail/__init__.py create mode 100644 core/mail/migrations/__init__.py create mode 100644 core/mail/models.py create mode 100644 core/mail/protocol.py create mode 100644 core/mail/socket.py create mode 100644 core/mail/tests/__init__.py create mode 100644 core/notify_sessions/models.py create mode 100644 core/notify_sessions/tests/__init__.py create mode 100644 core/notify_sessions/tests/test_notify_socket.py create mode 100644 web/src/views/Debug.vue diff --git a/core/core/globals.py b/core/core/globals.py new file mode 100644 index 0000000..d84a7a0 --- /dev/null +++ b/core/core/globals.py @@ -0,0 +1,29 @@ +import asyncio +import logging +import signal + +loop = asyncio.get_event_loop() + + +def create_task(coro): + global loop + loop.create_task(coro) + + +async def shutdown(sig, loop): + log = logging.getLogger() + log.info(f"Received exit signal {sig.name}...") + tasks = [t for t in asyncio.all_tasks() if t is not + asyncio.current_task()] + [task.cancel() for task in tasks] + log.info(f"Cancelling {len(tasks)} outstanding tasks") + await asyncio.wait_for(loop.shutdown_asyncgens(), timeout=10) + loop.stop() + log.info("Shutdown complete.") + + +def init_loop(): + global loop + loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(shutdown(signal.SIGTERM, loop))) + loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(shutdown(signal.SIGINT, loop))) + return loop diff --git a/core/core/settings.py b/core/core/settings.py index 615ecdc..79055c9 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ 'channels', 'files', 'inventory', + 'mail', 'notify_sessions', ] diff --git a/core/core/urls.py b/core/core/urls.py index cbc52c0..c115026 100644 --- a/core/core/urls.py +++ b/core/core/urls.py @@ -20,7 +20,7 @@ from django.urls import path, include from .version import get_info urlpatterns = [ - path('admin/', admin.site.urls), + path('djangoadmin/', admin.site.urls), path('api/1/', include('inventory.api_v1')), path('api/1/', include('files.api_v1')), path('api/1/', include('files.media_v1')), diff --git a/core/mail/__init__.py b/core/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/migrations/__init__.py b/core/mail/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/models.py b/core/mail/models.py new file mode 100644 index 0000000..e1fe527 --- /dev/null +++ b/core/mail/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django_softdelete.models import SoftDeleteModel +from inventory.models import Event +from issues.models import IssueThread + + +class Email(SoftDeleteModel): + id = models.AutoField(primary_key=True) + timestamp = models.DateTimeField(auto_now_add=True) + body = models.TextField() + subject = models.CharField(max_length=255) + sender = models.CharField(max_length=255) + recipient = models.CharField(max_length=255) + event = models.ForeignKey(Event, models.SET_DEFAULT, default=1) + issue = models.ForeignKey(IssueThread, models.SET_NULL, null=True) diff --git a/core/mail/protocol.py b/core/mail/protocol.py new file mode 100644 index 0000000..144931d --- /dev/null +++ b/core/mail/protocol.py @@ -0,0 +1,62 @@ +import asyncio +import logging + +from core.globals import create_task +from mail.models import Email +from notify_sessions.models import trigger_event +from asgiref.sync import sync_to_async + + +def make_reply(message, to, subject): + from email.message import EmailMessage + from core.settings import MAIL_DOMAIN + + reply = EmailMessage() + reply["From"] = "noreply@" + MAIL_DOMAIN + reply["To"] = to + reply["Subject"] = subject + reply.set_content(message) + + return reply + + +async def send_smtp(message): + import aiosmtplib + log = logging.getLogger('mail.log') + log.info('Sending message to %s' % message['To']) + + await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False) + + +async def deleyed_actions(from_email, reference): + await asyncio.sleep(1) + await trigger_event(None, "email received", reference) + await send_smtp(make_reply("Thank you for your message.", from_email, 'Message received')) + + +class LMTPHandler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + from core.settings import MAIL_DOMAIN + if not address.endswith('@' + MAIL_DOMAIN): + return '550 not relaying to that domain' + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + log = logging.getLogger('mail.log') + log.info('Message from %s' % envelope.mail_from) + log.info('Message for %s' % envelope.rcpt_tos) + log.info('Message data:\n') + for ln in envelope.content.decode('utf8', errors='replace').splitlines(): + log.info(f'> {ln}'.strip()) + log.info('End of message') + + mail = await sync_to_async(Email.objects.create, thread_sensitive=True)( + body=envelope.content.decode('utf8', errors='replace'), + subject=envelope.subject, + sender=envelope.mail_from, + recipient=envelope.rcpt_tos[0], + ) + create_task(deleyed_actions(envelope.mail_from, mail.id)) + + return '250 Message accepted for delivery' diff --git a/core/mail/socket.py b/core/mail/socket.py new file mode 100644 index 0000000..312b916 --- /dev/null +++ b/core/mail/socket.py @@ -0,0 +1,31 @@ +from abc import ABCMeta + +from aiosmtpd.controller import BaseController, UnixSocketMixin +from aiosmtpd.lmtp import LMTP + + +class BaseAsyncController(BaseController, metaclass=ABCMeta): + def __init__( + self, + handler, + loop, + **SMTP_parameters, + ): + super().__init__( + handler, + loop, + **SMTP_parameters, + ) + + def serve(self): + return self._create_server() + + +class UnixSocketLMTPController(UnixSocketMixin, BaseAsyncController): + def factory(self): + return LMTP(self.handler) + + def _trigger_server(self): # pragma: no-unixsock + # Prevent confusion on which _trigger_server() to invoke. + # Or so LGTM.com claimed + UnixSocketMixin._trigger_server(self) diff --git a/core/mail/tests/__init__.py b/core/mail/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/models.py b/core/notify_sessions/models.py new file mode 100644 index 0000000..619118a --- /dev/null +++ b/core/notify_sessions/models.py @@ -0,0 +1,25 @@ +from django.db import models +from django.contrib.auth.models import User +from asgiref.sync import sync_to_async +from channels.layers import get_channel_layer + + +class Event(models.Model): + id = models.AutoField(primary_key=True) + timestamp = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, models.SET_NULL, null=True) + type = models.CharField(max_length=255) + reference = models.IntegerField(blank=True, null=True) + + +async def trigger_event(user, type, reference=None): + event = await sync_to_async(Event.objects.create, thread_sensitive=True)(user=user, type=type, reference=reference) + channel_layer = get_channel_layer() + await channel_layer.group_send( + 'generals', + { + 'name': 'send_message_to_frontend', + 'message': "event_trigered_from_views", + 'event_id': event.id, + } + ) diff --git a/core/notify_sessions/tests/__init__.py b/core/notify_sessions/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/tests/test_notify_socket.py b/core/notify_sessions/tests/test_notify_socket.py new file mode 100644 index 0000000..80618cd --- /dev/null +++ b/core/notify_sessions/tests/test_notify_socket.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from channels.testing import ApplicationCommunicator, WebsocketCommunicator + +from notify_sessions.consumers import NotifyConsumer + + +# class NotifyConsumerTestCase(TestCase): +# +# async def test_connect(self): +# communicator = ApplicationCommunicator(NotifyConsumer.as_asgi(), { +# "type": "websocket.connect", +# "path": "/ws/2/notify/", +# "headers": [ +# (b'host', b'localhost:8000'), +# (b'connection', b'upgrade'), +# (b'pragma', b'no-cache'), +# (b'cache-control', b'no-cache'), +# (b'upgrade', b'websocket'), +# (b'origin', b'http://localhost:8000'), +# (b'sec-websocket-version', b'13'), +# (b'user-agent', b'Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0'), +# (b'accept', b'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'), +# (b'accept-language', b'en-US,en;q=0.5'), +# (b'sec-websocket-key', b''), +# (b'sec-websocket-extensions', b'permessage-deflate'), +# ], +# "query_string": b"", +# "client": [], +# "server": [], +# }) +# await communicator.send_input({ +# "type": "websocket.receive", +# "text": '{"name":"foo","message":"bar"}', +# }) + + +class NotifyWebsocketTestCase(TestCase): + + async def test_connect(self): + communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + await communicator.disconnect() + + async def test_send_message(self): + communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + await communicator.send_json_to({ + "name": "foo", + "message": "bar", + }) + response = await communicator.receive_json_from() + self.assertEqual(response, { + "message": "bar", + }) + await communicator.disconnect() diff --git a/core/requirements.prod.txt b/core/requirements.prod.txt index 2212d3c..b176517 100644 --- a/core/requirements.prod.txt +++ b/core/requirements.prod.txt @@ -1,30 +1,18 @@ aiosmtpd==1.4.4.post2 aiosmtplib==3.0.1 -anyio==4.0.0 asgiref==3.7.2 -async-timeout==4.0.3 -atpublic==4.0 attrs==23.1.0 certifi==2023.11.17 channels==4.0.0 channels-redis==4.1.0 -charset-normalizer==3.3.2 -click==8.1.7 coreapi==2.3.3 coreschema==0.0.4 -coverage==7.3.2 Django==4.2.7 django-extensions==3.2.3 django-mysql==4.12.0 django-soft-delete==0.9.21 djangorestframework==3.14.0 drf-yasg==1.21.7 -h11==0.14.0 -httptools==0.6.1 -idna==3.4 -inflection==0.5.1 -itypes==1.2.0 -Jinja2==3.1.2 MarkupSafe==2.1.3 msgpack==1.0.7 msgpack-python==0.5.6 @@ -35,11 +23,9 @@ Pillow==10.1.0 python-dotenv==1.0.0 pytz==2023.3.post1 PyYAML==6.0.1 -redis==5.0.1 requests==2.31.0 sdnotify==0.3.2 setproctitle==1.3.3 -six==1.16.0 sniffio==1.3.0 sqlparse==0.4.4 typing_extensions==4.8.0 diff --git a/deploy/ansible/playbooks/templates/nginx.conf.j2 b/deploy/ansible/playbooks/templates/nginx.conf.j2 index 9834392..3484bc6 100644 --- a/deploy/ansible/playbooks/templates/nginx.conf.j2 +++ b/deploy/ansible/playbooks/templates/nginx.conf.j2 @@ -43,7 +43,7 @@ server { proxy_pass http://c3lf-sys3; } - location /admin { + location /djangoadmin { proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/web/src/App.vue b/web/src/App.vue index 72309a6..e9bd946 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -23,7 +23,9 @@ export default { components: {Toast, Navbar, AddItemModal}, computed: mapState(['loadedItems', 'layout', 'toasts']), data: () => ({ - addModalOpen: false + addModalOpen: false, + notify_socket: null, + socket_toast: null, }), methods: { ...mapMutations(['removeToast', 'createToast']), @@ -32,35 +34,64 @@ export default { }, closeAddModal() { this.addModalOpen = false; + }, + tryConnect() { + if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) { + if (this.socket_toast) { + this.removeToast(this.socket_toast.key); + this.socket_toast = null; + } + this.socket_toast = this.createToast({ + title: "Connecting...", + message: "Connecting to websocket...", + color: "warning" + }); + this.notify_socket = new WebSocket('wss://' + window.location.host + '/ws/2/notify/'); + this.notify_socket.onopen = (e) => { + if (this.socket_toast) { + this.removeToast(this.socket_toast.key); + this.socket_toast = null; + } + this.socket_toast = this.createToast({ + title: "Connection established", + message: JSON.stringify(e), + color: "success" + }); + }; + this.notify_socket.onclose = (e) => { + if (this.socket_toast) { + this.removeToast(this.socket_toast.key); + this.socket_toast = null; + } + this.socket_toast = this.createToast({ + title: "Connection closed", + message: JSON.stringify(e), + color: "danger" + }); + setTimeout(() => { + this.tryConnect(); + }, 1000); + }; + this.notify_socket.onerror = (e) => { + if (this.socket_toast) { + this.removeToast(this.socket_toast.key); + this.socket_toast = null; + } + this.socket_toast = this.createToast({ + title: "Connection error", + message: JSON.stringify(e), + color: "danger" + }); + setTimeout(() => { + this.tryConnect(); + }, 1000); + }; + + } } }, created: function () { - this.notify_socket = new WebSocket('wss://' + window.location.host + '/ws/notify/'); - this.notify_socket.onmessage = (e) => { - let data = JSON.parse(e.data); - console.log(data, e.data); - }; - this.notify_socket.onopen = (e) => { - this.createToast({ - title: "Connection established", - message: JSON.stringify(e), - color: "success" - }); - }; - this.notify_socket.onclose = (e) => { - this.createToast({ - title: "Connection closed", - message: JSON.stringify(e), - color: "danger" - }); - }; - this.notify_socket.onerror = (e) => { - this.createToast({ - title: "Connection error", - message: JSON.stringify(e), - color: "danger" - }); - }; + this.tryConnect(); } }; diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 6c4e613..cd50c6a 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -77,7 +77,11 @@ export default { //{'title':'mass-edit','path':'massedit'}, ], links: [ - {'title':'howto engel','path':'/howto/'} + {'title':'howto engel','path':'/howto/'}, + {'title':'events','path':'/admin/events/'}, + {'title':'files','path':'/admin/files/'}, + {'title':'users','path':'/admin/users/'}, + {'title':'debug','path':'/admin/debug/'}, ] }), computed: { diff --git a/web/src/router.js b/web/src/router.js index 983d822..f238470 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -6,6 +6,7 @@ import Error from './views/Error'; import HowTo from './views/HowTo'; import VueRouter from 'vue-router'; import Vue from 'vue'; +import Debug from "@/views/Debug.vue"; @@ -16,6 +17,7 @@ const routes = [ { path: '/howto', name: 'howto', component: HowTo}, { path: '/admin/files', name: 'files', component: Files}, { path: '/admin/events', name: 'events', component: Events}, + { path: '/admin/debug', name: 'debug', component: Debug}, { path: '/:event/boxes', name: 'boxes', component: Boxes}, { path: '/:event/items', name: 'items', component: Items}, { path: '/:event/box/:uid', name: 'boxes', component: Boxes}, diff --git a/web/src/store/index.js b/web/src/store/index.js index 01c37b0..df4ab5a 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -48,7 +48,7 @@ const store = new Vuex.Store({ lastUsed: localStorage.getItem('lf_lastUsed') || {}, }, getters: { - getEventSlug: state => state.route && state.route.params.event? state.route.params.event : state.events.length ? state.events[0].slug : '36C3', + getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.events.length ? state.events[0].slug : '36C3', getActiveView: state => state.route.name || 'items', getFilters: state => state.route.query, getBoxes: state => state.loadedBoxes @@ -74,68 +74,70 @@ const store = new Vuex.Store({ state.loadedBoxes = loadedBoxes; }, updateItem(state, updatedItem) { - const item = state.loadedItems.filter(({ uid }) => uid === updatedItem.uid)[0]; + const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0]; Object.assign(item, updatedItem); }, removeItem(state, item) { - state.loadedItems = state.loadedItems.filter(it => it !== item ); + state.loadedItems = state.loadedItems.filter(it => it !== item); }, appendItem(state, item) { state.loadedItems.push(item); }, - createToast(state, { title, message, color }) { - state.toasts.push({ title, message, color, key: state.keyIncrement }); + createToast(state, {title, message, color}) { + var toast = {title, message, color, key: state.keyIncrement} + state.toasts.push(toast); state.keyIncrement += 1; + return toast; }, removeToast(state, key) { state.toasts = state.toasts.filter(toast => toast.key !== key); } }, actions: { - async loadEvents({ commit }) { - const { data } = await axios.get('/1/events'); + async loadEvents({commit}) { + const {data} = await axios.get('/1/events'); commit('replaceEvents', data); }, - changeEvent({ dispatch, getters}, eventName) { + changeEvent({dispatch, getters}, eventName) { router.push({path: `/${eventName.slug}/${getters.getActiveView}`}); dispatch('loadEventItems'); }, - changeView({ getters }, link) { + changeView({getters}, link) { router.push({path: `/${getters.getEventSlug}/${link.path}`}); }, - showBoxContent({ getters }, box) { + showBoxContent({getters}, box) { router.push({path: `/${getters.getEventSlug}/items`, query: {box}}); }, - async loadEventItems({ commit, getters }) { - const { data } = await axios.get(`/1/${getters.getEventSlug}/items`); + async loadEventItems({commit, getters}) { + const {data} = await axios.get(`/1/${getters.getEventSlug}/items`); commit('replaceLoadedItems', data); }, - async searchEventItems({ commit, getters }, query) { + async searchEventItems({commit, getters}, query) { const foo = utf8.encode(query); const bar = base64.encode(foo); const {data} = await axios.get(`/1/${getters.getEventSlug}/items/${bar}`); commit('replaceLoadedItems', data); }, - async loadBoxes({ commit }) { - const { data } = await axios.get('/1/boxes'); + async loadBoxes({commit}) { + const {data} = await axios.get('/1/boxes'); commit('replaceBoxes', data); }, - async updateItem({ commit, getters }, item) { - const { data } = await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, item); + async updateItem({commit, getters}, item) { + const {data} = await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, item); commit('updateItem', data); }, - async markItemReturned({ commit, getters }, item) { + async markItemReturned({commit, getters}, item) { await axios.put(`/1/${getters.getEventSlug}/item/${item.uid}`, {returned: true}); commit('removeItem', item); }, - async deleteItem({ commit, getters }, item) { + async deleteItem({commit, getters}, item) { await axios.delete(`/1/${getters.getEventSlug}/item/${item.uid}`, item); - commit('removeItem',item); + commit('removeItem', item); }, - async postItem({ commit, getters }, item) { - commit('updateLastUsed',{box: item.box, cid: item.cid}); - const { data } = await axios.post(`/1/${getters.getEventSlug}/item`, item); + async postItem({commit, getters}, item) { + commit('updateLastUsed', {box: item.box, cid: item.cid}); + const {data} = await axios.post(`/1/${getters.getEventSlug}/item`, item); commit('appendItem', data); } } @@ -143,7 +145,7 @@ const store = new Vuex.Store({ export default store; -store.dispatch('loadEvents').then(() =>{ +store.dispatch('loadEvents').then(() => { store.dispatch('loadEventItems'); store.dispatch('loadBoxes'); }); diff --git a/web/src/views/Debug.vue b/web/src/views/Debug.vue new file mode 100644 index 0000000..c1fc8aa --- /dev/null +++ b/web/src/views/Debug.vue @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file