diff --git a/src/snek/app.py b/src/snek/app.py index 9822d2d..4bae513 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -5,6 +5,7 @@ import ssl import uuid import signal from datetime import datetime +from contextlib import asynccontextmanager from snek import snode from snek.view.threads import ThreadsView @@ -230,6 +231,7 @@ class Application(BaseApplication): except Exception as ex: print(ex) self.db.commit() + async def prepare_database(self, app): self.db.query("PRAGMA journal_mode=WAL") @@ -245,7 +247,7 @@ class Application(BaseApplication): except: pass - await app.services.drive.prepare_all() + await self.services.drive.prepare_all() self.loop.create_task(self.task_runner()) def setup_router(self): @@ -406,6 +408,11 @@ class Application(BaseApplication): self.jinja2_env.loader = await self.get_user_template_loader( request.session.get("uid") ) + + try: + context["nonce"] = request['csp_nonce'] + except: + context['nonce'] = '?' rendered = await super().render_template(template, request, context) @@ -452,7 +459,28 @@ class Application(BaseApplication): template_paths.append(self.template_path) return FileSystemLoader(template_paths) - + + @asynccontextmanager + async def no_save(self): + stats = { + 'count': 0 + } + async def patched_save(*args, **kwargs): + await self.cache.set(args[0]["uid"], args[0]) + stats['count'] = stats['count'] + 1 + print(f"save is ignored {stats['count']} times") + return args[0] + save_original = self.services.channel_message.mapper.save + self.services.channel_message.mapper.save = patched_save + raised_exception = None + try: + yield + except Exception as ex: + raised_exception = ex + finally: + self.services.channel_message.mapper.save = save_original + if raised_exception: + raise raised_exception app = Application(db_path="sqlite:///snek.db") diff --git a/src/snek/model/channel.py b/src/snek/model/channel.py index 0a90c39..6e32335 100644 --- a/src/snek/model/channel.py +++ b/src/snek/model/channel.py @@ -11,11 +11,15 @@ class ChannelModel(BaseModel): is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True) index = ModelField(name="index", required=True, kind=int, value=1000) last_message_on = ModelField(name="last_message_on", required=False, kind=str) + history_start = ModelField(name="history_start", required=False, kind=str) async def get_last_message(self) -> ChannelMessageModel: + history_start_filter = "" + if self["history_start"]: + history_start_filter = f" AND created_at > '{self['history_start']}' " try: async for model in self.app.services.channel_message.query( - "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1", + "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1", {"channel_uid": self["uid"]}, ): diff --git a/src/snek/service/channel.py b/src/snek/service/channel.py index 0af0a4b..5d15683 100644 --- a/src/snek/service/channel.py +++ b/src/snek/service/channel.py @@ -113,6 +113,12 @@ class ChannelService(BaseService): channel = await self.get(uid=channel_member["channel_uid"]) yield channel + async def clear(self, channel_uid): + model = await self.get(uid=channel_uid) + model['history_from'] = datetime.now() + await self.save(model) + + async def ensure_public_channel(self, created_by_uid): model = await self.get(is_listed=True, tag="public") is_moderator = False diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py index bfc5954..8dbeae7 100644 --- a/src/snek/service/channel_message.py +++ b/src/snek/service/channel_message.py @@ -6,8 +6,10 @@ class ChannelMessageService(BaseService): mapper_name = "channel_message" async def maintenance(self): + args = {} async for message in self.find(): updated_at = message["updated_at"] + message["is_final"] = True html = message["html"] await self.save(message) @@ -20,8 +22,21 @@ class ChannelMessageService(BaseService): ) if html != message["html"]: print("Reredefined message", message["uid"]) + + while True: + changed = 0 + async for message in self.find(is_final=False): + message["is_final"] = True + await self.save(message) + changed += 1 + async for message in self.find(is_final=None): + message["is_final"] = False + await self.save(message) + changed += 1 + if not changed: + break - + async def create(self, channel_uid, user_uid, message, is_final=True): model = await self.new() @@ -87,13 +102,20 @@ class ChannelMessageService(BaseService): model["html"] = whitelist_attributes(model["html"]) return await super().save(model) + async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): + channel = await self.services.channel.get(uid=channel_uid) + if not channel: + return [] + history_start_filter = "" + if channel["history_start"]: + history_start_filter = f" AND created_at > '{channel['history_start']}'" results = [] offset = page * page_size try: if timestamp: async for model in self.query( - "SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", + f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", { "channel_uid": channel_uid, "page_size": page_size, @@ -104,7 +126,7 @@ class ChannelMessageService(BaseService): results.append(model) elif page > 0: async for model in self.query( - "SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size", + f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size", { "channel_uid": channel_uid, "page_size": page_size, @@ -115,7 +137,7 @@ class ChannelMessageService(BaseService): results.append(model) else: async for model in self.query( - "SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", + f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", { "channel_uid": channel_uid, "page_size": page_size, @@ -124,7 +146,7 @@ class ChannelMessageService(BaseService): ): results.append(model) - except: - pass + except Exception as ex: + print(ex) results.sort(key=lambda x: x["created_at"]) return results diff --git a/src/snek/service/chat.py b/src/snek/service/chat.py index 9130292..c62b795 100644 --- a/src/snek/service/chat.py +++ b/src/snek/service/chat.py @@ -36,9 +36,21 @@ class ChatService(BaseService): channel = await self.services.channel.get(uid=channel_uid) if not channel: raise Exception("Channel not found.") - channel_message = await self.services.channel_message.create( - channel_uid, user_uid, message, is_final + channel_message = await self.services.channel_message.get( + channel_uid=channel_uid,user_uid=user_uid, is_final=False ) + if channel_message: + channel_message["message"] = message + channel_message["is_final"] = is_final + if not channel_message["is_final"]: + async with self.app.no_save(): + await self.services.channel_message.save(channel_message) + else: + await self.services.channel_message.save(channel_message) + else: + channel_message = await self.services.channel_message.create( + channel_uid, user_uid, message, is_final + ) channel_message_uid = channel_message["uid"] user = await self.services.user.get(uid=user_uid) diff --git a/src/snek/service/user.py b/src/snek/service/user.py index 34dc468..09b2a09 100644 --- a/src/snek/service/user.py +++ b/src/snek/service/user.py @@ -12,6 +12,7 @@ class UserService(BaseService): async def search(self, query, **kwargs): query = query.strip().lower() + kwarggs["deleted_at"] = None if not query: return [] results = [] diff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js index 0800f61..ecebb0c 100644 --- a/src/snek/static/chat-input.js +++ b/src/snek/static/chat-input.js @@ -1,22 +1,23 @@ import { app } from "./app.js"; -import { NjetComponent } from "./njet.js"; +import { NjetComponent,eventBus } from "./njet.js"; import { FileUploadGrid } from "./file-upload-grid.js"; + class ChatInputComponent extends NjetComponent { autoCompletions = { - "example 1": () => { - }, - "example 2": () => { - }, - } + "example 1": () => {}, + "example 2": () => {}, + }; + hiddenCompletions = { "/starsRender": () => { app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", "")) } - } - users = [] - textarea = null - _value = "" - lastUpdateEvent = null + }; + + users = []; + textarea = null; + _value = ""; + lastUpdateEvent = null; expiryTimer = null; queuedMessage = null; lastMessagePromise = null; @@ -38,7 +39,7 @@ class ChatInputComponent extends NjetComponent { } get allAutoCompletions() { - return Object.assign({}, this.autoCompletions, this.hiddenCompletions) + return Object.assign({}, this.autoCompletions, this.hiddenCompletions); } resolveAutoComplete(input) { @@ -65,7 +66,7 @@ class ChatInputComponent extends NjetComponent { } getAuthors() { - return this.users.flatMap((user) => [user.username, user.nick]) + return this.users.flatMap((user) => [user.username, user.nick]); } extractMentions(text) { @@ -82,17 +83,14 @@ class ChatInputComponent extends NjetComponent { const lowerAuthor = author.toLowerCase(); let distance = this.levenshteinDistance(lowerMention, lowerAuthor); - if (!this.isSubsequence(lowerMention, lowerAuthor)) { - distance += 10 + distance += 10; } if (distance < minDistance) { minDistance = distance; closestAuthor = author; - } - }); return { mention, closestAuthor, distance: minDistance }; @@ -128,7 +126,6 @@ class ChatInputComponent extends NjetComponent { return matrix[b.length][a.length]; } - replaceMentionsWithAuthors(text) { const authors = this.getAuthors(); const mentions = this.extractMentions(text); @@ -143,21 +140,19 @@ class ChatInputComponent extends NjetComponent { return updatedText; } - async connectedCallback() { - this.user = null + this.user = null; app.rpc.getUser(null).then((user) => { - this.user = user - }) + this.user = user; + }); - this.liveType = this.getAttribute("live-type") === "true"; - this.liveTypeInterval = - parseInt(this.getAttribute("live-type-interval")) || 6; + this.liveType = this.getAttribute("live-type") !== "true"; + this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6; this.channelUid = this.getAttribute("channel"); app.rpc.getRecentUsers(this.channelUid).then(users => { - this.users = users - }) + this.users = users; + }); this.messageUid = null; this.classList.add("chat-input"); @@ -181,24 +176,36 @@ class ChatInputComponent extends NjetComponent { this.dispatchEvent(new CustomEvent("uploaded", e)); }); this.uploadButton.addEventListener("click", (e) => { - // e.preventDefault(); - // this.fileUploadGrid.openFileDialog() - - }) - this.subscribe("file-uploading", (e) => { - - this.fileUploadGrid.style.display = "block"; - - this.uploadButton.style.display = "none"; - this.textarea.style.display = "none"; + e.preventDefault(); + this.fileUploadGrid.openFileDialog(); + }); + eventBus.subscribe("file-uploading", (e) => { + this.fileUploadGrid.style.display = "block"; + this.uploadButton.style.display = "none"; + this.textarea.style.display = "none"; }) + document.eventBus = eventBus; this.appendChild(this.uploadButton); + this.textarea.addEventListener("blur", () => { this.updateFromInput(""); }); + + eventBus.subscribe("file-uploads-done", (data)=>{ + console.info("JEEJ", data) + this.textarea.style.display = "block"; + this.uploadButton.style.display = "block"; + this.fileUploadGrid.style.display = "none"; + let message =data.reduce((file) => { + return `${message}[${file.filename}](/channel/attachment/${file.file})`; + }, ''); + app.rpc.sendMessage(this.channelUid, message, true); + }); + + + this.textarea.addEventListener("keyup", (e) => { if (e.key === "Enter" && !e.shiftKey) { - const message = this.replaceMentionsWithAuthors(this.value); e.target.value = ""; @@ -210,12 +217,10 @@ class ChatInputComponent extends NjetComponent { autoCompletionHandler(); this.value = ""; e.target.value = ""; - return; } - this.finalizeMessage(this.messageUid) - + this.finalizeMessage(this.messageUid); return; } @@ -254,9 +259,10 @@ class ChatInputComponent extends NjetComponent { }, ''); app.rpc.sendMessage(this.channelUid, message, true); }); + setTimeout(() => { this.focus(); - }, 1000) + }, 1000); } trackSecondsBetweenEvents(event1Time, event2Time) { @@ -278,33 +284,16 @@ class ChatInputComponent extends NjetComponent { flagTyping() { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) { this.lastUpdateEvent = new Date(); - app.rpc.set_typing(this.channelUid, this.user.color).catch(() => { - }); + app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {}); } } - finalizeMessage(messageUid) { - if (!messageUid) { - if (this.value.trim() === "") { - return; - } - this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType); - } else if (messageUid.startsWith("?")) { - const lastQueuedMessage = this.queuedMessage; - - this.lastMessagePromise?.then((uid) => { - const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve(); - return updatePromise.finally(() => { - return app.rpc.finalizeMessage(uid); - }) - }) - } else { - app.rpc.finalizeMessage(messageUid) - } + async finalizeMessage(messageUid) { + await app.rpc.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), true); this.value = ""; this.messageUid = null; this.queuedMessage = null; - this.lastMessagePromise = null + this.lastMessagePromise = null; } updateFromInput(value) { @@ -315,39 +304,12 @@ class ChatInputComponent extends NjetComponent { this.value = value; - this.flagTyping() + this.flagTyping(); if (this.liveType && value[0] !== "/") { - this.expiryTimer = setTimeout(() => { - this.finalizeMessage(this.messageUid) - }, this.liveTypeInterval * 1000); - - const messageText = this.replaceMentionsWithAuthors(value); - if (this.messageUid?.startsWith("?")) { - this.queuedMessage = messageText; - } else if (this.messageUid) { - app.rpc.updateMessageText(this.messageUid, messageText).then((d) => { - if (!d.success) { - this.messageUid = null - this.updateFromInput(value) - } - }) - } else { - const placeHolderId = "?" + crypto.randomUUID(); - this.messageUid = placeHolderId; - - this.lastMessagePromise = this.sendMessage(this.channelUid, messageText, !this.liveType).then(async (uid) => { - if (this.liveType && this.messageUid === placeHolderId) { - if (this.queuedMessage && this.queuedMessage !== messageText) { - await app.rpc.updateMessageText(uid, this.queuedMessage) - } - this.messageUid = uid; - } - - return uid - }); - } + this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType); + return this.messageUid; } } @@ -360,3 +322,4 @@ class ChatInputComponent extends NjetComponent { } customElements.define("chat-input", ChatInputComponent); + diff --git a/src/snek/static/editor.js b/src/snek/static/editor.js new file mode 100644 index 0000000..8ee3bd3 --- /dev/null +++ b/src/snek/static/editor.js @@ -0,0 +1,226 @@ +import { NjetComponent} from "/njet.js" + + class NjetEditor extends NjetComponent { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = ` + #editor { + padding: 1rem; + outline: none; + white-space: pre-wrap; + line-height: 1.5; + height: 100%; + overflow-y: auto; + background: #1e1e1e; + color: #d4d4d4; + } + #command-line { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0.2rem 1rem; + background: #333; + color: #0f0; + display: none; + font-family: monospace; + } + `; + + this.editor = document.createElement('div'); + this.editor.id = 'editor'; + this.editor.contentEditable = true; + this.editor.innerText = `Welcome to VimEditor Component +Line 2 here +Another line +Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`; + + this.cmdLine = document.createElement('div'); + this.cmdLine.id = 'command-line'; + this.shadowRoot.append(style, this.editor, this.cmdLine); + + this.mode = 'normal'; // normal | insert | visual | command + this.keyBuffer = ''; + this.lastDeletedLine = ''; + this.yankedLine = ''; + + this.editor.addEventListener('keydown', this.handleKeydown.bind(this)); + } + + connectedCallback() { + this.editor.focus(); + } + + getCaretOffset() { + let caretOffset = 0; + const sel = this.shadowRoot.getSelection(); + if (!sel || sel.rangeCount === 0) return 0; + + const range = sel.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(this.editor); + preCaretRange.setEnd(range.endContainer, range.endOffset); + caretOffset = preCaretRange.toString().length; + return caretOffset; + } + + setCaretOffset(offset) { + const range = document.createRange(); + const sel = this.shadowRoot.getSelection(); + const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false); + + let currentOffset = 0; + let node; + while ((node = walker.nextNode())) { + if (currentOffset + node.length >= offset) { + range.setStart(node, offset - currentOffset); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + currentOffset += node.length; + } + } + + handleKeydown(e) { + const key = e.key; + + if (this.mode === 'insert') { + if (key === 'Escape') { + e.preventDefault(); + this.mode = 'normal'; + this.editor.blur(); + this.editor.focus(); + } + return; + } + + if (this.mode === 'command') { + if (key === 'Enter' || key === 'Escape') { + e.preventDefault(); + this.cmdLine.style.display = 'none'; + this.mode = 'normal'; + this.keyBuffer = ''; + } + return; + } + + if (this.mode === 'visual') { + if (key === 'Escape') { + e.preventDefault(); + this.mode = 'normal'; + } + return; + } + + // Handle normal mode + this.keyBuffer += key; + + const text = this.editor.innerText; + const caretPos = this.getCaretOffset(); + const lines = text.split('\n'); + + let charCount = 0, lineIdx = 0; + for (let i = 0; i < lines.length; i++) { + if (caretPos <= charCount + lines[i].length) { + lineIdx = i; + break; + } + charCount += lines[i].length + 1; + } + + const offsetToLine = idx => + text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0); + + switch (this.keyBuffer) { + case 'i': + e.preventDefault(); + this.mode = 'insert'; + this.keyBuffer = ''; + break; + + case 'v': + e.preventDefault(); + this.mode = 'visual'; + this.keyBuffer = ''; + break; + + case ':': + e.preventDefault(); + this.mode = 'command'; + this.cmdLine.style.display = 'block'; + this.cmdLine.textContent = ':'; + this.keyBuffer = ''; + break; + + case 'yy': + e.preventDefault(); + this.yankedLine = lines[lineIdx]; + this.keyBuffer = ''; + break; + + case 'dd': + e.preventDefault(); + this.lastDeletedLine = lines[lineIdx]; + lines.splice(lineIdx, 1); + this.editor.innerText = lines.join('\n'); + this.setCaretOffset(offsetToLine(lineIdx)); + this.keyBuffer = ''; + break; + + case 'p': + e.preventDefault(); + const lineToPaste = this.yankedLine || this.lastDeletedLine; + if (lineToPaste) { + lines.splice(lineIdx + 1, 0, lineToPaste); + this.editor.innerText = lines.join('\n'); + this.setCaretOffset(offsetToLine(lineIdx + 1)); + } + this.keyBuffer = ''; + break; + + case '0': + e.preventDefault(); + this.setCaretOffset(offsetToLine(lineIdx)); + this.keyBuffer = ''; + break; + + case '$': + e.preventDefault(); + this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length); + this.keyBuffer = ''; + break; + + case 'gg': + e.preventDefault(); + this.setCaretOffset(0); + this.keyBuffer = ''; + break; + + case 'G': + e.preventDefault(); + this.setCaretOffset(text.length); + this.keyBuffer = ''; + break; + + case 'Escape': + e.preventDefault(); + this.mode = 'normal'; + this.keyBuffer = ''; + this.cmdLine.style.display = 'none'; + break; + + default: + // allow up to 2 chars for combos + if (this.keyBuffer.length > 2) this.keyBuffer = ''; + break; + } + } + } + + customElements.define('njet-editor', NjetEditor); +export {NjetEditor} diff --git a/src/snek/static/file-upload-grid.js b/src/snek/static/file-upload-grid.js index 3aae7ba..730f718 100644 --- a/src/snek/static/file-upload-grid.js +++ b/src/snek/static/file-upload-grid.js @@ -57,6 +57,7 @@ class FileUploadGrid extends NjetComponent { } reset(){ + this.uploadResponses = []; this.uploadsDone = 0; this.uploadsStarted = 0; this._grid.innerHTML = ''; @@ -70,6 +71,7 @@ class FileUploadGrid extends NjetComponent { this.reset() this.uploadsDone = 0; this.uploadsStarted = files.length; + [...files].forEach(file => this.createTile(file)); } connectedCallback() { @@ -130,7 +132,8 @@ class FileUploadGrid extends NjetComponent { startUpload(file, tile, progress) { this.publish('file-uploading', {file: file, tile: tile, progress: progress}); - + console.info("File uploading",file) + const protocol = location.protocol === "https:" ? "wss://" : "ws://"; const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`); ws.binaryType = 'arraybuffer'; @@ -148,7 +151,7 @@ class FileUploadGrid extends NjetComponent { }; ws.onmessage = (event) => { - + console.info(event.data) const data = JSON.parse(event.data); if (data.type === 'progress') { @@ -156,14 +159,20 @@ class FileUploadGrid extends NjetComponent { progress.style.width = pct + '%'; this.publish('file-uploading', {file: file, tile: tile, progress: progress}); } else if (data.type === 'done') { - + console.info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH") + console.info("Done") + console.info(this.uploadResponses) this.uploadsDone += 1; this.publish('file-uploaded', {file: file, tile: tile, progress: progress}); progress.style.width = '100%'; tile.classList.add('fug-done'); - + console.info("Closed") ws.close(); - + this.uploadResponses.push({file:file, remoteFile:data.file}) + console.info(this.uploadsDone, this.uploadsStarted) + if(this.uploadsDone == this.uploadsStarted){ + this.publish('file-uploads-done', this.uploadResponses); + } this.reset() } }; diff --git a/src/snek/static/njet.js b/src/snek/static/njet.js index 65aee05..9223942 100644 --- a/src/snek/static/njet.js +++ b/src/snek/static/njet.js @@ -210,19 +210,18 @@ class Njet extends HTMLElement { customElements.define(name, component); } - constructor(config = {}) { + constructor() { super(); if (!Njet._root) { Njet._root = this - Njet._rest = new RestClient({ baseURL: config.baseURL || null }) + Njet._rest = new RestClient({ baseURL: '/' || null }) } this.root._elements.push(this) this.classList.add('njet'); - this.config = config; this.render.call(this); - this.initProps(config); - if (typeof this.construct === 'function') - this.construct.call(this) + //this.initProps(config); + //if (typeof this.config.construct === 'function') + // this.config.construct.call(this) } initProps(config) { @@ -284,9 +283,12 @@ class Njet extends HTMLElement { render() {} } +Njet.registerComponent('njet-root', Njet); class Component extends Njet {} +Njet.registerComponent('njet-component', Component); + class NjetPanel extends Component { render() { this.innerHTML = ''; @@ -491,18 +493,67 @@ document.body.appendChild(dialog); */ class NjetComponent extends Component {} - const njet = Njet; + const njet = Njet njet.showDialog = function(args){ const dialog = new NjetDialog(args) dialog.show() return dialog } + +class EventBus extends EventTarget { + constructor() { + super(); + this.eventMap = new Map(); + } + + subscribe(eventName, callback) { + this.addEventListener(eventName, callback); + + if (!this.eventMap.has(eventName)) { + this.eventMap.set(eventName, []); + } + this.eventMap.get(eventName).push(callback); + } + + publish(eventName, detail = {}) { + const event = new CustomEvent(eventName, { + detail, + bubbles: true, + cancelable: true + }); + + document.dispatchEvent(event); + } + + unsubscribe(eventName, callback) { + this.removeEventListener(eventName, callback); + + const subscribers = this.eventMap.get(eventName); + if (subscribers) { + const index = subscribers.indexOf(callback); + if (index > -1) subscribers.splice(index, 1); + } + } +} + +const eventBus = new EventBus() + + njet.showWindow = function(args) { const w = new NjetWindow(args) w.show() return w } +njet.publish = function(event, data) { + if (this.root._subscriptions[event]) { + this.root._subscriptions[event].forEach(callback => callback(data)) + } +} +njet.subscribe = function(event, callback) { + if (!this.root._subscriptions[event]) { + this.root._subscriptions[event] = [] + } + this.root._subscriptions[event].push(callback) +} -window.njet = njet - -export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow }; +export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus }; diff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js index 243fc8b..a789c96 100644 --- a/src/snek/static/upload-button.js +++ b/src/snek/static/upload-button.js @@ -112,9 +112,9 @@ class UploadButtonElement extends HTMLElement { this.channelUid = this.getAttribute("channel"); this.uploadButton = this.container.querySelector(".upload-button"); this.fileInput = this.container.querySelector(".hidden-input"); - this.uploadButton.addEventListener("click", () => { + /*this.uploadButton.addEventListener("click", () => { this.fileInput.click(); - }); + });*/ this.fileInput.addEventListener("change", () => { this.uploadFiles(); }); diff --git a/src/snek/system/cache.py b/src/snek/system/cache.py index 8f8cdc3..19b8ebf 100644 --- a/src/snek/system/cache.py +++ b/src/snek/system/cache.py @@ -14,7 +14,7 @@ class Cache: self.cache = {} self.max_items = max_items self.stats = {} - self.enabled = False + self.enabled = True self.lru = [] self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4 diff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py index fef7784..bda128a 100644 --- a/src/snek/system/mapper.py +++ b/src/snek/system/mapper.py @@ -25,9 +25,20 @@ class BaseMapper: return asyncio.get_event_loop() async def run_in_executor(self, func, *args, **kwargs): - async with self.semaphore: - return func(*args, **kwargs) - # return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs)) + use_semaphore = kwargs.pop("use_semaphore", False) + if use_semaphore: + async with self.semaphore: + database_exception = None + for x in range(20): + try: + result = func(*args, **kwargs) + self.db.commit() + return result + except Exception as ex: + await asyncio.sleep(0) + database_exception = ex + raise database_exception + return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs)) async def new(self): return self.model_class(mapper=self, app=self.app) @@ -39,7 +50,8 @@ class BaseMapper: async def get(self, uid: str = None, **kwargs) -> BaseModel: if uid: kwargs["uid"] = uid - + if not kwargs.get("deleted_at"): + kwargs["deleted_at"] = None record = await self.run_in_executor(self.table.find_one, **kwargs) if not record: return None @@ -48,7 +60,6 @@ class BaseMapper: for key, value in record.items(): model[key] = value return model - return await self.model_class.from_record(mapper=self, record=record) async def exists(self, **kwargs): return await self.run_in_executor(self.table.exists, **kwargs) @@ -60,26 +71,39 @@ class BaseMapper: if not model.record.get("uid"): raise Exception(f"Attempt to save without uid: {model.record}.") model.updated_at.update() - return await self.run_in_executor(self.table.upsert, model.record, ["uid"]) + return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True) async def find(self, **kwargs) -> typing.AsyncGenerator: if not kwargs.get("_limit"): kwargs["_limit"] = self.default_limit + if not kwargs.get("deleted_at"): + kwargs["deleted_at"] = None for record in await self.run_in_executor(self.table.find, **kwargs): model = await self.new() for key, value in record.items(): model[key] = value yield model + async def _use_semaphore(self, sql): + sql = sql.lower().strip() + return "insert" in sql or "update" in sql or "delete" in sql + async def query(self, sql, *args): - for record in await self.run_in_executor(self.db.query, sql, *args): + for record in await self.run_in_executor(self.db.query, sql, *args, use_semaphore=await self._use_semaphore(sql)): yield dict(record) async def update(self, model): + if not model["deleted_at"] is None: + raise Exception("Can't update deleted record.") model.updated_at.update() - return await self.run_in_executor(self.table.update, model.record, ["uid"]) + return await self.run_in_executor(self.table.update, model.record, ["uid"],use_semaphore=True) + + async def upsert(self, model): + model.updated_at.update() + return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True) async def delete(self, **kwargs) -> int: if not kwargs or not isinstance(kwargs, dict): raise Exception("Can't execute delete with no filter.") + kwargs["use_semaphore"] = True return await self.run_in_executor(self.table.delete, **kwargs) diff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py index 93e5eaf..993051a 100644 --- a/src/snek/system/middleware.py +++ b/src/snek/system/middleware.py @@ -1,35 +1,33 @@ # Written by retoor@molodetz.nl -# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers. - -# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library. - -# MIT License: This code is distributed under the MIT License. +# This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers. import secrets - from aiohttp import web -csp_policy = ( - "default-src 'self'; " - "script-src 'self' https://*.cloudflare.com https://molodetz.nl 'nonce-{nonce}'; " - "style-src 'self' https://*.cloudflare.com https://molodetz.nl; " - "img-src 'self' https://*.cloudflare.com https://molodetz.nl data:; " - "connect-src 'self' https://*.cloudflare.com https://molodetz.nl;" -) - - -def generate_nonce(): - return secrets.token_hex(16) - @web.middleware async def csp_middleware(request, handler): - + nonce = secrets.token_hex(16) + origin = request.headers.get('Origin') + csp_policy = ( + "default-src 'self'; " + f"script-src 'self' {origin} 'nonce-{nonce}'; " + f"style-src 'self' 'unsafe-inline' {origin} 'nonce-{nonce}'; " + "img-src *; " + "connect-src 'self' https://umami.molodetz.nl; " + "font-src *; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-src 'self'; " + "worker-src *; " + "media-src *; " + "manifest-src 'self';" + ) + request['csp_nonce'] = nonce response = await handler(request) - return response - nonce = generate_nonce() - response.headers["Content-Security-Policy"] = csp_policy.format(nonce=nonce) + #response.headers['Content-Security-Policy'] = csp_policy return response @@ -39,7 +37,6 @@ async def no_cors_middleware(request, handler): response.headers.pop("Access-Control-Allow-Origin", None) return response - @web.middleware async def cors_allow_middleware(request, handler): response = await handler(request) @@ -51,7 +48,6 @@ async def cors_allow_middleware(request, handler): response.headers["Access-Control-Allow-Credentials"] = "true" return response - @web.middleware async def auth_middleware(request, handler): request["user"] = None @@ -61,7 +57,6 @@ async def auth_middleware(request, handler): ) return await handler(request) - @web.middleware async def cors_middleware(request, handler): if request.headers.get("Allow"): diff --git a/src/snek/system/service.py b/src/snek/system/service.py index bf0c9d6..41c87a4 100644 --- a/src/snek/system/service.py +++ b/src/snek/system/service.py @@ -40,11 +40,11 @@ class BaseService: yield record async def get(self, uid=None, **kwargs): + kwargs["deleted_at"] = None if uid: - if not kwargs: - result = await self.cache.get(uid) - if False and result and result.__class__ == self.mapper.model_class: - return result + result = await self.cache.get(uid) + if result and result.__class__ == self.mapper.model_class: + return result kwargs["uid"] = uid result = await self.mapper.get(**kwargs) @@ -52,7 +52,7 @@ class BaseService: await self.cache.set(result["uid"], result) return result - async def save(self, model: UserModel): + async def save(self, model): # if model.is_valid: You Know why not if await self.mapper.save(model): await self.cache.set(model["uid"], model) diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index be50cdf..f5b0481 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -18,6 +18,7 @@ + @@ -30,7 +31,7 @@ - +
diff --git a/src/snek/templates/sandbox.html b/src/snek/templates/sandbox.html index 4949247..7e2a038 100644 --- a/src/snek/templates/sandbox.html +++ b/src/snek/templates/sandbox.html @@ -1,6 +1,6 @@
-