This commit is contained in:
j3d1 2023-11-23 23:17:20 +01:00
parent d52575aa42
commit bc03855291
27 changed files with 474 additions and 76 deletions

29
core/core/globals.py Normal file
View file

@ -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

View file

@ -50,7 +50,9 @@ INSTALLED_APPS = [
'drf_yasg',
'channels',
'files',
'issues',
'inventory',
'mail',
'notify_sessions',
]

View file

@ -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')),

0
core/issues/__init__.py Normal file
View file

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.7 on 2023-11-24 05:16
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('inventory', '0002_container_deleted_at_container_is_deleted_and_more'),
]
operations = [
migrations.CreateModel(
name='IssueThread',
fields=[
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('id', models.AutoField(primary_key=True, serialize=False)),
('event', models.ForeignKey(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, to='inventory.event')),
],
options={
'abstract': False,
},
),
]

View file

9
core/issues/models.py Normal file
View file

@ -0,0 +1,9 @@
from django.db import models
from django_softdelete.models import SoftDeleteModel
from inventory.models import Event
class IssueThread(SoftDeleteModel):
id = models.AutoField(primary_key=True)
event = models.ForeignKey(Event, models.SET_DEFAULT, default=1)

View file

0
core/mail/__init__.py Normal file
View file

View file

@ -0,0 +1,35 @@
# Generated by Django 4.2.7 on 2023-11-24 05:16
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('issues', '0001_initial'),
('inventory', '0002_container_deleted_at_container_is_deleted_and_more'),
]
operations = [
migrations.CreateModel(
name='Email',
fields=[
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('id', models.AutoField(primary_key=True, serialize=False)),
('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(default=1, on_delete=django.db.models.deletion.SET_DEFAULT, to='inventory.event')),
('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='issues.issuethread')),
],
options={
'abstract': False,
},
),
]

View file

15
core/mail/models.py Normal file
View file

@ -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)

66
core/mail/protocol.py Normal file
View file

@ -0,0 +1,66 @@
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')
try:
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'
except Exception as e:
log.error(e)
return '550 Message rejected'

31
core/mail/socket.py Normal file
View file

@ -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)

View file

View file

@ -1,3 +1,4 @@
import logging
from json.decoder import JSONDecodeError
from json import loads as json_loads
from json import dumps as json_dumps
@ -12,12 +13,9 @@ class NotifyConsumer(AsyncWebsocketConsumer):
# self.event_slug = None
async def connect(self):
# self.event_slug = self.scope["url_route"]["kwargs"]["event_slug"]
# self.room_group_name = f"chat_{self.event_slug}"
# Join room group
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
logging.info(f"Added {self.channel_name} channel to {self.room_group_name} group")
await self.accept()
async def disconnect(self, close_code):
@ -25,14 +23,14 @@ class NotifyConsumer(AsyncWebsocketConsumer):
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
# Receive message from WebSocket
async def receive(self, text_data):
async def receive(self, text_data=None, bytes_data=None):
try:
text_data_json = json_loads(text_data)
message = text_data_json["message"]
# Send message to room group
await self.channel_layer.group_send(
self.room_group_name, {"type": "chat.message", "message": message}
self.room_group_name, {"type": "generic.event", "message": message, "name": "send_message_to_frontend", "event_id": 1}
)
except JSONDecodeError:
await self.send(text_data=json_dumps({"message": "error", "error": "malformed json"}))
@ -43,8 +41,11 @@ class NotifyConsumer(AsyncWebsocketConsumer):
raise e
# Receive message from room group
async def chat_message(self, event):
async def generic_event(self, event):
logging.info(f"Received event: {event}")
message = event["message"]
name = event["name"]
event_id = event["event_id"]
# Send message to WebSocket
await self.send(text_data=json_dumps({"message": message}))
await self.send(text_data=json_dumps({"message": message, "name": name, "event_id": event_id}))

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.7 on 2023-11-24 05:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('type', models.CharField(max_length=255)),
('reference', models.IntegerField(blank=True, null=True)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,31 @@
import logging
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(
'general',
{
'type': 'generic.event',
'name': 'send_message_to_frontend',
'message': "event_trigered_from_views",
'event_id': event.id,
}
)
logger = logging.getLogger('server.log')
logger.info(f"Event {event.id} triggered")
return event

View file

View file

@ -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()

View file

@ -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

View file

@ -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;

View file

@ -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,67 @@ 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);
};
this.notify_socket.onmessage = (e) => {
let data = JSON.parse(e.data);
console.log(data);
}
}
},
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();
}
};
</script>

View file

@ -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: {

View file

@ -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},

View file

@ -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');
});

39
web/src/views/Debug.vue Normal file
View file

@ -0,0 +1,39 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['slug', 'name']"
:items="events"
:keyName="'slug'"
v-slot="{ item }"
>
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)" >
<font-awesome-icon icon="archive"/> use
</button>
<button class="btn btn-danger" @click.stop="" >
<font-awesome-icon icon="trash"/> delete
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Debug',
components: {Table},
computed: mapState(['events']),
methods: mapActions(['changeEvent']),
};
</script>
<style scoped>
</style>