Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

21 changed files with 211 additions and 603 deletions

View File

@ -5,7 +5,6 @@ import ssl
import uuid import uuid
import signal import signal
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager
from snek import snode from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
@ -232,7 +231,6 @@ class Application(BaseApplication):
print(ex) print(ex)
self.db.commit() self.db.commit()
async def prepare_database(self, app): async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL") self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off") self.db.query("PRAGMA syncnorm=off")
@ -247,7 +245,7 @@ class Application(BaseApplication):
except: except:
pass pass
await self.services.drive.prepare_all() await app.services.drive.prepare_all()
self.loop.create_task(self.task_runner()) self.loop.create_task(self.task_runner())
def setup_router(self): def setup_router(self):
@ -409,11 +407,6 @@ class Application(BaseApplication):
request.session.get("uid") request.session.get("uid")
) )
try:
context["nonce"] = request['csp_nonce']
except:
context['nonce'] = '?'
rendered = await super().render_template(template, request, context) rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader self.jinja2_env.loader = self.original_loader
@ -460,27 +453,6 @@ class Application(BaseApplication):
template_paths.append(self.template_path) template_paths.append(self.template_path)
return FileSystemLoader(template_paths) 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") app = Application(db_path="sqlite:///snek.db")

View File

@ -11,15 +11,11 @@ class ChannelModel(BaseModel):
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True) is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
index = ModelField(name="index", required=True, kind=int, value=1000) index = ModelField(name="index", required=True, kind=int, value=1000)
last_message_on = ModelField(name="last_message_on", required=False, kind=str) 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: 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: try:
async for model in self.app.services.channel_message.query( async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):

View File

@ -113,12 +113,6 @@ class ChannelService(BaseService):
channel = await self.get(uid=channel_member["channel_uid"]) channel = await self.get(uid=channel_member["channel_uid"])
yield channel 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): async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public") model = await self.get(is_listed=True, tag="public")
is_moderator = False is_moderator = False

View File

@ -6,10 +6,8 @@ class ChannelMessageService(BaseService):
mapper_name = "channel_message" mapper_name = "channel_message"
async def maintenance(self): async def maintenance(self):
args = {}
async for message in self.find(): async for message in self.find():
updated_at = message["updated_at"] updated_at = message["updated_at"]
message["is_final"] = True
html = message["html"] html = message["html"]
await self.save(message) await self.save(message)
@ -23,19 +21,6 @@ class ChannelMessageService(BaseService):
if html != message["html"]: if html != message["html"]:
print("Reredefined message", message["uid"]) 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): async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new() model = await self.new()
@ -102,20 +87,13 @@ class ChannelMessageService(BaseService):
model["html"] = whitelist_attributes(model["html"]) model["html"] = whitelist_attributes(model["html"])
return await super().save(model) return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): 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 = [] results = []
offset = page * page_size offset = page * page_size
try: try:
if timestamp: if timestamp:
async for model in self.query( async for model in self.query(
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", "SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -126,7 +104,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
elif page > 0: elif page > 0:
async for model in self.query( async for model in self.query(
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", "SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -137,7 +115,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
else: else:
async for model in self.query( async for model in self.query(
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", "SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -146,7 +124,7 @@ class ChannelMessageService(BaseService):
): ):
results.append(model) results.append(model)
except Exception as ex: except:
print(ex) pass
results.sort(key=lambda x: x["created_at"]) results.sort(key=lambda x: x["created_at"])
return results return results

View File

@ -36,18 +36,6 @@ class ChatService(BaseService):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
raise Exception("Channel not found.") raise Exception("Channel not found.")
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_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final channel_uid, user_uid, message, is_final
) )

View File

@ -12,7 +12,6 @@ class UserService(BaseService):
async def search(self, query, **kwargs): async def search(self, query, **kwargs):
query = query.strip().lower() query = query.strip().lower()
kwarggs["deleted_at"] = None
if not query: if not query:
return [] return []
results = [] results = []

View File

@ -1,23 +1,22 @@
import { app } from "./app.js"; import { app } from "./app.js";
import { NjetComponent,eventBus } from "./njet.js"; import { NjetComponent } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js"; import { FileUploadGrid } from "./file-upload-grid.js";
class ChatInputComponent extends NjetComponent { class ChatInputComponent extends NjetComponent {
autoCompletions = { autoCompletions = {
"example 1": () => {}, "example 1": () => {
"example 2": () => {}, },
}; "example 2": () => {
},
}
hiddenCompletions = { hiddenCompletions = {
"/starsRender": () => { "/starsRender": () => {
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", "")) app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
} }
}; }
users = []
users = []; textarea = null
textarea = null; _value = ""
_value = ""; lastUpdateEvent = null
lastUpdateEvent = null;
expiryTimer = null; expiryTimer = null;
queuedMessage = null; queuedMessage = null;
lastMessagePromise = null; lastMessagePromise = null;
@ -39,7 +38,7 @@ class ChatInputComponent extends NjetComponent {
} }
get allAutoCompletions() { get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions); return Object.assign({}, this.autoCompletions, this.hiddenCompletions)
} }
resolveAutoComplete(input) { resolveAutoComplete(input) {
@ -66,7 +65,7 @@ class ChatInputComponent extends NjetComponent {
} }
getAuthors() { getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick]); return this.users.flatMap((user) => [user.username, user.nick])
} }
extractMentions(text) { extractMentions(text) {
@ -83,14 +82,17 @@ class ChatInputComponent extends NjetComponent {
const lowerAuthor = author.toLowerCase(); const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor); let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) { if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10; distance += 10
} }
if (distance < minDistance) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
closestAuthor = author; closestAuthor = author;
} }
}); });
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
@ -126,6 +128,7 @@ class ChatInputComponent extends NjetComponent {
return matrix[b.length][a.length]; return matrix[b.length][a.length];
} }
replaceMentionsWithAuthors(text) { replaceMentionsWithAuthors(text) {
const authors = this.getAuthors(); const authors = this.getAuthors();
const mentions = this.extractMentions(text); const mentions = this.extractMentions(text);
@ -140,19 +143,21 @@ class ChatInputComponent extends NjetComponent {
return updatedText; return updatedText;
} }
async connectedCallback() {
this.user = null;
app.rpc.getUser(null).then((user) => {
this.user = user;
});
this.liveType = this.getAttribute("live-type") !== "true"; async connectedCallback() {
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6; this.user = null
app.rpc.getUser(null).then((user) => {
this.user = user
})
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users => { app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users; this.users = users
}); })
this.messageUid = null; this.messageUid = null;
this.classList.add("chat-input"); this.classList.add("chat-input");
@ -176,36 +181,24 @@ class ChatInputComponent extends NjetComponent {
this.dispatchEvent(new CustomEvent("uploaded", e)); this.dispatchEvent(new CustomEvent("uploaded", e));
}); });
this.uploadButton.addEventListener("click", (e) => { this.uploadButton.addEventListener("click", (e) => {
e.preventDefault(); // e.preventDefault();
this.fileUploadGrid.openFileDialog(); // this.fileUploadGrid.openFileDialog()
});
eventBus.subscribe("file-uploading", (e) => { })
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block"; this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none"; this.uploadButton.style.display = "none";
this.textarea.style.display = "none"; this.textarea.style.display = "none";
}) })
document.eventBus = eventBus;
this.appendChild(this.uploadButton); this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => { this.textarea.addEventListener("blur", () => {
this.updateFromInput(""); 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) => { this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
const message = this.replaceMentionsWithAuthors(this.value); const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = ""; e.target.value = "";
@ -217,10 +210,12 @@ class ChatInputComponent extends NjetComponent {
autoCompletionHandler(); autoCompletionHandler();
this.value = ""; this.value = "";
e.target.value = ""; e.target.value = "";
return; return;
} }
this.finalizeMessage(this.messageUid); this.finalizeMessage(this.messageUid)
return; return;
} }
@ -259,10 +254,9 @@ class ChatInputComponent extends NjetComponent {
}, ''); }, '');
app.rpc.sendMessage(this.channelUid, message, true); app.rpc.sendMessage(this.channelUid, message, true);
}); });
setTimeout(() => { setTimeout(() => {
this.focus(); this.focus();
}, 1000); }, 1000)
} }
trackSecondsBetweenEvents(event1Time, event2Time) { trackSecondsBetweenEvents(event1Time, event2Time) {
@ -284,16 +278,33 @@ class ChatInputComponent extends NjetComponent {
flagTyping() { flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {}); app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
});
} }
} }
async finalizeMessage(messageUid) { finalizeMessage(messageUid) {
await app.rpc.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), true); 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)
}
this.value = ""; this.value = "";
this.messageUid = null; this.messageUid = null;
this.queuedMessage = null; this.queuedMessage = null;
this.lastMessagePromise = null; this.lastMessagePromise = null
} }
updateFromInput(value) { updateFromInput(value) {
@ -304,12 +315,39 @@ class ChatInputComponent extends NjetComponent {
this.value = value; this.value = value;
this.flagTyping(); this.flagTyping()
if (this.liveType && value[0] !== "/") { if (this.liveType && value[0] !== "/") {
this.expiryTimer = setTimeout(() => {
this.finalizeMessage(this.messageUid)
}, this.liveTypeInterval * 1000);
const messageText = this.replaceMentionsWithAuthors(value); const messageText = this.replaceMentionsWithAuthors(value);
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType); if (this.messageUid?.startsWith("?")) {
return this.messageUid; 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
});
}
} }
} }
@ -322,4 +360,3 @@ class ChatInputComponent extends NjetComponent {
} }
customElements.define("chat-input", ChatInputComponent); customElements.define("chat-input", ChatInputComponent);

View File

@ -1,226 +0,0 @@
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}

View File

@ -57,7 +57,6 @@ class FileUploadGrid extends NjetComponent {
} }
reset(){ reset(){
this.uploadResponses = [];
this.uploadsDone = 0; this.uploadsDone = 0;
this.uploadsStarted = 0; this.uploadsStarted = 0;
this._grid.innerHTML = ''; this._grid.innerHTML = '';
@ -71,7 +70,6 @@ class FileUploadGrid extends NjetComponent {
this.reset() this.reset()
this.uploadsDone = 0; this.uploadsDone = 0;
this.uploadsStarted = files.length; this.uploadsStarted = files.length;
[...files].forEach(file => this.createTile(file)); [...files].forEach(file => this.createTile(file));
} }
connectedCallback() { connectedCallback() {
@ -132,7 +130,6 @@ class FileUploadGrid extends NjetComponent {
startUpload(file, tile, progress) { startUpload(file, tile, progress) {
this.publish('file-uploading', {file: file, tile: tile, progress: progress}); this.publish('file-uploading', {file: file, tile: tile, progress: progress});
console.info("File uploading",file)
const protocol = location.protocol === "https:" ? "wss://" : "ws://"; const protocol = location.protocol === "https:" ? "wss://" : "ws://";
const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`); const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`);
@ -151,7 +148,7 @@ class FileUploadGrid extends NjetComponent {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
console.info(event.data)
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'progress') { if (data.type === 'progress') {
@ -159,20 +156,14 @@ class FileUploadGrid extends NjetComponent {
progress.style.width = pct + '%'; progress.style.width = pct + '%';
this.publish('file-uploading', {file: file, tile: tile, progress: progress}); this.publish('file-uploading', {file: file, tile: tile, progress: progress});
} else if (data.type === 'done') { } else if (data.type === 'done') {
console.info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH")
console.info("Done")
console.info(this.uploadResponses)
this.uploadsDone += 1; this.uploadsDone += 1;
this.publish('file-uploaded', {file: file, tile: tile, progress: progress}); this.publish('file-uploaded', {file: file, tile: tile, progress: progress});
progress.style.width = '100%'; progress.style.width = '100%';
tile.classList.add('fug-done'); tile.classList.add('fug-done');
console.info("Closed")
ws.close(); 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() this.reset()
} }
}; };

View File

@ -210,18 +210,19 @@ class Njet extends HTMLElement {
customElements.define(name, component); customElements.define(name, component);
} }
constructor() { constructor(config = {}) {
super(); super();
if (!Njet._root) { if (!Njet._root) {
Njet._root = this Njet._root = this
Njet._rest = new RestClient({ baseURL: '/' || null }) Njet._rest = new RestClient({ baseURL: config.baseURL || null })
} }
this.root._elements.push(this) this.root._elements.push(this)
this.classList.add('njet'); this.classList.add('njet');
this.config = config;
this.render.call(this); this.render.call(this);
//this.initProps(config); this.initProps(config);
//if (typeof this.config.construct === 'function') if (typeof this.construct === 'function')
// this.config.construct.call(this) this.construct.call(this)
} }
initProps(config) { initProps(config) {
@ -283,12 +284,9 @@ class Njet extends HTMLElement {
render() {} render() {}
} }
Njet.registerComponent('njet-root', Njet);
class Component extends Njet {} class Component extends Njet {}
Njet.registerComponent('njet-component', Component);
class NjetPanel extends Component { class NjetPanel extends Component {
render() { render() {
this.innerHTML = ''; this.innerHTML = '';
@ -493,67 +491,18 @@ document.body.appendChild(dialog);
*/ */
class NjetComponent extends Component {} class NjetComponent extends Component {}
const njet = Njet const njet = Njet;
njet.showDialog = function(args){ njet.showDialog = function(args){
const dialog = new NjetDialog(args) const dialog = new NjetDialog(args)
dialog.show() dialog.show()
return dialog 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) { njet.showWindow = function(args) {
const w = new NjetWindow(args) const w = new NjetWindow(args)
w.show() w.show()
return w 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)
}
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus }; window.njet = njet
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow };

View File

@ -112,9 +112,9 @@ class UploadButtonElement extends HTMLElement {
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button"); this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input"); this.fileInput = this.container.querySelector(".hidden-input");
/*this.uploadButton.addEventListener("click", () => { this.uploadButton.addEventListener("click", () => {
this.fileInput.click(); this.fileInput.click();
});*/ });
this.fileInput.addEventListener("change", () => { this.fileInput.addEventListener("change", () => {
this.uploadFiles(); this.uploadFiles();
}); });

View File

@ -14,7 +14,7 @@ class Cache:
self.cache = {} self.cache = {}
self.max_items = max_items self.max_items = max_items
self.stats = {} self.stats = {}
self.enabled = True self.enabled = False
self.lru = [] self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4 self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4

View File

@ -25,20 +25,9 @@ class BaseMapper:
return asyncio.get_event_loop() return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs): async def run_in_executor(self, func, *args, **kwargs):
use_semaphore = kwargs.pop("use_semaphore", False)
if use_semaphore:
async with self.semaphore: async with self.semaphore:
database_exception = None return func(*args, **kwargs)
for x in range(20): # return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
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): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@ -50,8 +39,7 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel: async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid: if uid:
kwargs["uid"] = 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) record = await self.run_in_executor(self.table.find_one, **kwargs)
if not record: if not record:
return None return None
@ -60,6 +48,7 @@ class BaseMapper:
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
return model return model
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs): async def exists(self, **kwargs):
return await self.run_in_executor(self.table.exists, **kwargs) return await self.run_in_executor(self.table.exists, **kwargs)
@ -71,39 +60,26 @@ class BaseMapper:
if not model.record.get("uid"): if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.") raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update() model.updated_at.update()
return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True) return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
async def find(self, **kwargs) -> typing.AsyncGenerator: async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_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): for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new() model = await self.new()
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
yield model 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): async def query(self, sql, *args):
for record in await self.run_in_executor(self.db.query, sql, *args, use_semaphore=await self._use_semaphore(sql)): for record in await self.run_in_executor(self.db.query, sql, *args):
yield dict(record) yield dict(record)
async def update(self, model): async def update(self, model):
if not model["deleted_at"] is None:
raise Exception("Can't update deleted record.")
model.updated_at.update() model.updated_at.update()
return await self.run_in_executor(self.table.update, model.record, ["uid"],use_semaphore=True) return await self.run_in_executor(self.table.update, model.record, ["uid"])
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: async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict): if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.") raise Exception("Can't execute delete with no filter.")
kwargs["use_semaphore"] = True
return await self.run_in_executor(self.table.delete, **kwargs) return await self.run_in_executor(self.table.delete, **kwargs)

View File

@ -1,33 +1,35 @@
# Written by retoor@molodetz.nl # Written by retoor@molodetz.nl
# This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers. # 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.
import secrets import secrets
from aiohttp import web 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 @web.middleware
async def csp_middleware(request, handler): 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) response = await handler(request)
#response.headers['Content-Security-Policy'] = csp_policy return response
nonce = generate_nonce()
response.headers["Content-Security-Policy"] = csp_policy.format(nonce=nonce)
return response return response
@ -37,6 +39,7 @@ async def no_cors_middleware(request, handler):
response.headers.pop("Access-Control-Allow-Origin", None) response.headers.pop("Access-Control-Allow-Origin", None)
return response return response
@web.middleware @web.middleware
async def cors_allow_middleware(request, handler): async def cors_allow_middleware(request, handler):
response = await handler(request) response = await handler(request)
@ -48,6 +51,7 @@ async def cors_allow_middleware(request, handler):
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
return response return response
@web.middleware @web.middleware
async def auth_middleware(request, handler): async def auth_middleware(request, handler):
request["user"] = None request["user"] = None
@ -57,6 +61,7 @@ async def auth_middleware(request, handler):
) )
return await handler(request) return await handler(request)
@web.middleware @web.middleware
async def cors_middleware(request, handler): async def cors_middleware(request, handler):
if request.headers.get("Allow"): if request.headers.get("Allow"):

View File

@ -40,10 +40,10 @@ class BaseService:
yield record yield record
async def get(self, uid=None, **kwargs): async def get(self, uid=None, **kwargs):
kwargs["deleted_at"] = None
if uid: if uid:
if not kwargs:
result = await self.cache.get(uid) result = await self.cache.get(uid)
if result and result.__class__ == self.mapper.model_class: if False and result and result.__class__ == self.mapper.model_class:
return result return result
kwargs["uid"] = uid kwargs["uid"] = uid
@ -52,7 +52,7 @@ class BaseService:
await self.cache.set(result["uid"], result) await self.cache.set(result["uid"], result)
return result return result
async def save(self, model): async def save(self, model: UserModel):
# if model.is_valid: You Know why not # if model.is_valid: You Know why not
if await self.mapper.save(model): if await self.mapper.save(model):
await self.cache.set(model["uid"], model) await self.cache.set(model["uid"], model)

View File

@ -151,14 +151,6 @@ SAFE_ATTRIBUTES = {
"aria-hidden", "aria-hidden",
"aria-label", "aria-label",
"srcset", "srcset",
"target",
"rel",
"referrerpolicy",
"controls",
"frameborder",
"allow",
"allowfullscreen",
"referrerpolicy",
} }
@ -188,7 +180,6 @@ def embed_youtube(text):
or url.hostname or url.hostname
in [ in [
"www.youtube.com", "www.youtube.com",
"music.youtube.com",
"youtube.com", "youtube.com",
"www.youtube-nocookie.com", "www.youtube-nocookie.com",
"youtube-nocookie.com", "youtube-nocookie.com",
@ -339,7 +330,7 @@ def embed_url(text):
attachments = {} attachments = {}
for element in soup.find_all("a"): for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http") and ("data-noembed" not in element.attrs): if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"]) page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"]) page = get_url_content(element.attrs["href"])
if page: if page:
@ -456,7 +447,7 @@ def embed_url(text):
description_element.append( description_element.append(
BeautifulSoup( BeautifulSoup(
f"<p class='page-description'>{page_description or 'No description available.'}</p>", f"<p class='page-description'>{page_description or "No description available."}</p>",
"html.parser", "html.parser",
) )
) )
@ -565,4 +556,3 @@ class PythonExtension(Extension):
return "".join(to_write) return "".join(to_write)
return str(fn(caller())) return str(fn(caller()))

View File

@ -18,7 +18,6 @@
<script src="/generic-form.js" type="module"></script> <script src="/generic-form.js" type="module"></script>
<script src="/html-frame.js" type="module"></script> <script src="/html-frame.js" type="module"></script>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>
<script src="/editor.js" type="module"></script>
<script src="/file-manager.js" type="module"></script> <script src="/file-manager.js" type="module"></script>
<script src="/user-list.js"></script> <script src="/user-list.js"></script>
<script src="/message-list.js" type="module"></script> <script src="/message-list.js" type="module"></script>
@ -31,7 +30,7 @@
<link rel="stylesheet" href="/base.css"> <link rel="stylesheet" href="/base.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64"> <link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
<script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script> <script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head> </head>
<body> <body>
<header> <header>

View File

@ -1,6 +1,6 @@
<div id="star-tooltip" class="star-tooltip"></div> <div id="star-tooltip" class="star-tooltip"></div>
<div id="star-popup" class="star-popup"></div> <div id="star-popup" class="star-popup"></div>
<script type="module" nonce="{{nonce}}"> <script type="module">
import { app } from "/app.js"; import { app } from "/app.js";
import {WebTerminal} from "/dumb-term.js"; import {WebTerminal} from "/dumb-term.js";

View File

@ -192,22 +192,16 @@ class ChannelAttachmentUploadView(BaseView):
channel_uid=channel_uid, name=filename, user_uid=user_uid channel_uid=channel_uid, name=filename, user_uid=user_uid
) )
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 file:
print("File openend.", filename)
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.BINARY: if msg.type == web.WSMsgType.BINARY:
print("Binary",filename)
if file is not None: if file is not None:
await file.write(msg.data) await file.write(msg.data)
await ws.send_json({"type": "progress", "filename": filename, "bytes": await file.tell()}) await ws.send_json({"type": "progress", "filename": filename, "bytes": file.tell()})
elif msg.type == web.WSMsgType.TEXT: elif msg.type == web.WSMsgType.TEXT:
print("TExt",filename)
print(msg.json())
data = msg.json() data = msg.json()
if data.get('type') == 'end': if data.get('type') == 'end':
relative_url = urllib.parse.quote(attachment_record["relative_url"]) await ws.send_json({"type": "done", "filename": filename})
await ws.send_json({"type": "done", "file": relative_url, "filename": filename})
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
break break
return ws return ws

View File

@ -30,7 +30,6 @@ class RPCView(BaseView):
self.ws = ws self.ws = ws
self.user_session = {} self.user_session = {}
self._scheduled = [] self._scheduled = []
self._finalize_task = None
async def _session_ensure(self): async def _session_ensure(self):
uid = await self.view.session_get("uid") uid = await self.view.session_get("uid")
@ -204,13 +203,6 @@ class RPCView(BaseView):
) )
return channels return channels
async def clear_channel(self, channel_uid):
self._require_login()
user = await self.services.user.get(uid=self.user_uid)
if not user["is_admin"]:
raise Exception("Not allowed")
return await self.services.channel_message.clear(channel_uid)
async def write_container(self, channel_uid, content,timeout=3): async def write_container(self, channel_uid, content,timeout=3):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
@ -267,21 +259,6 @@ class RPCView(BaseView):
} }
return result return result
async def send_message(self, channel_uid, message, is_final=True):
self._require_login()
#if not is_final:
# check_message = await self.services.channel_message.get(channel_uid=channel_uid, user_uid=self.user_uid,is_final=False)
# if check_message:
# return await self.update_message_text(check_message["uid"], message)
is_final = True
message = await self.services.chat.send(
self.user_uid, channel_uid, message, is_final
)
return message["uid"]
async def start_container(self, channel_uid): async def start_container(self, channel_uid):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
@ -309,8 +286,6 @@ class RPCView(BaseView):
raise Exception("Not allowed") raise Exception("Not allowed")
return await self.services.container.get_status(channel_uid) return await self.services.container.get_status(channel_uid)
async def finalize_message(self, message_uid): async def finalize_message(self, message_uid):
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
@ -326,13 +301,13 @@ class RPCView(BaseView):
return True return True
async def update_message_text(self, message_uid, text): async def update_message_text(self, message_uid, text):
async with self.app.no_save():
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
if message["user_uid"] != self.user_uid: if message["user_uid"] != self.user_uid:
raise Exception("Not allowed") raise Exception("Not allowed")
if message.get_seconds_since_last_update() > 5: if message.get_seconds_since_last_update() > 3:
await self.finalize_message(message["uid"])
return { return {
"error": "Message too old", "error": "Message too old",
"seconds_since_last_update": message.get_seconds_since_last_update(), "seconds_since_last_update": message.get_seconds_since_last_update(),
@ -361,21 +336,13 @@ class RPCView(BaseView):
return {"success": True} return {"success": True}
async def send_message(self, channel_uid, message, is_final=True):
async def clear_channel(self, channel_uid):
self._require_login() self._require_login()
user = await self.services.user.get(uid=self.user_uid) message = await self.services.chat.send(
if not user["is_admin"]: self.user_uid, channel_uid, message, is_final
raise Exception("Not allowed") )
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found")
channel['history_start'] = datetime.now()
await self.services.channel.save(channel)
return await self.services.channel_message.clear(channel_uid)
return message["uid"]
async def echo(self, *args): async def echo(self, *args):
self._require_login() self._require_login()

View File

@ -71,7 +71,6 @@ class WebView(BaseView):
await self.app.services.channel_member.save(channel_member) await self.app.services.channel_member.save(channel_member)
user = await self.services.user.get(uid=self.session.get("uid")) user = await self.services.user.get(uid=self.session.get("uid"))
messages = [ messages = [
await self.app.services.channel_message.to_extended_dict(message) await self.app.services.channel_message.to_extended_dict(message)
for message in await self.app.services.channel_message.offset( for message in await self.app.services.channel_message.offset(
@ -82,7 +81,7 @@ class WebView(BaseView):
await self.app.services.notification.mark_as_read( await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"] self.session.get("uid"), message["uid"]
) )
print(messages)
name = await channel_member.get_name() name = await channel_member.get_name()
return await self.render_template( return await self.render_template(
"web.html", "web.html",