Compare commits

...
Author SHA1 Message Date
retoor 35786703d5 Merge pull request 'Help relieve issues where network is maybe too slow?' (#52) from BordedDev/snek:bugfix/msg-finalized-before-update into main
Reviewed-on: #52
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-06 11:20:17 +02:00
retoor 10eec5fd6d Update ntsh 2025-06-06 03:46:19 +02:00
retoor d4debeab74 Update. 2025-06-06 03:45:07 +02:00
retoor 5c0ea360cd Update. 2025-06-06 03:41:39 +02:00
retoor 3b38e30df1 Update. 2025-06-06 03:41:06 +02:00
retoor 9a39bedd3a Update. 2025-06-06 03:39:33 +02:00
retoor 9e1eb9f1e5 Update. 2025-06-06 03:36:42 +02:00
retoor 82f8a1ef4a Update. 2025-06-06 03:35:31 +02:00
retoor 7c815898ea Update. 2025-06-06 03:34:47 +02:00
retoor 58a951eec9 Update. 2025-06-06 03:33:45 +02:00
retoor ef75cb3341 MAde elements forbidden. 2025-06-06 03:28:05 +02:00
retoor 1c71c0016b Update. 2025-06-06 03:22:39 +02:00
retoor 1a034041ab Update Security. 2025-06-06 03:04:37 +02:00
retoor c60f9ff4d3 Update. 2025-06-06 02:34:32 +02:00
retoor 3efe388d3f Fixed escape. 2025-06-06 02:22:03 +02:00
retoor 7dc12c9e7f Update flag. 2025-06-06 02:10:28 +02:00
retoor 19c88d786e Merge pull request 'Add styles for spoiler message functionality' (#53) from BordedDev/snek:feat/spoilers into main
Reviewed-on: #53
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-06 01:32:07 +02:00
BordedDev 9937f532ec Add styles for spoiler message functionality 2025-06-06 01:22:40 +02:00
BordedDev 13476bddf6 Help relieve issues where network is maybe too slow?
Also polished message handling a little
2025-06-05 19:41:43 +02:00
retoor 31d08ec973 Merge pull request 'Refactored message logic to fix issues where they desync' (#51) from BordedDev/snek:bugfix/typing-desync into main
Reviewed-on: #51
2025-06-01 22:07:51 +02:00
BordedDev deaa7716a2 Removed some dead code 2025-06-01 20:57:28 +02:00
BordedDev 157493b0f4 Simplified some code 2025-06-01 20:56:24 +02:00
BordedDev f7e1708039 Compacted code 2025-06-01 20:53:02 +02:00
BordedDev 20f817506f Refactored message logic 2025-06-01 20:48:17 +02:00
retoor 24ddd4b294 Merge pull request 'Fix image zoom URL handling to remove width and height parameters instead of all search params' (#50) from BordedDev/snek:bugfix/fix-image-zoom-url into main
Reviewed-on: #50
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-01 12:05:35 +02:00
retoor 557b34b71a Update. 2025-06-01 09:42:06 +02:00
retoor e0255b28ec Update. 2025-06-01 03:38:12 +02:00
retoor 69855fa118 Update. 2025-06-01 03:33:58 +02:00
retoor a07f2680d6 Update. 2025-06-01 03:24:14 +02:00
BordedDev a17bdc7e13 Merge branch 'main' into bugfix/fix-image-zoom-url 2025-06-01 00:53:17 +02:00
BordedDev 5711618e6e Fix image zoom URL handling to remove width and height parameters instead of all search params 2025-06-01 00:52:02 +02:00
retoor d022cff499 Update. 2025-06-01 00:39:53 +02:00
retoor d4a480b5ea Update. 2025-06-01 00:38:22 +02:00
retoor 161ff392d7 Update. 2025-06-01 00:33:47 +02:00
retoor 4e72fbf84b Merge pull request 'Make database asnyc.' (#49) from feat/make-database-async into main
Reviewed-on: #49
2025-06-01 00:28:34 +02:00
14 changed files with 424 additions and 257 deletions
+2
View File
@@ -38,6 +38,8 @@ dependencies = [
"humanize", "humanize",
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location",
"bleach"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
BIN
View File
Binary file not shown.
+41 -5
View File
@@ -8,7 +8,7 @@ from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
import json import json
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
from ipaddress import ip_address
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from aiohttp import web from aiohttp import web
@@ -20,7 +20,7 @@ from aiohttp_session import (
from aiohttp_session.cookie_storage import EncryptedCookieStorage from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
import IP2Location
from snek.sssh import start_ssh_server from snek.sssh import start_ssh_server
from snek.docs.app import Application as DocsApplication from snek.docs.app import Application as DocsApplication
from snek.mapper import get_mappers from snek.mapper import get_mappers
@@ -28,7 +28,7 @@ from snek.service import get_services
from snek.system import http from snek.system import http
from snek.system.cache import Cache from snek.system.cache import Cache
from snek.system.markdown import MarkdownExtension from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler from snek.system.profiler import profiler_handler
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
from snek.view.about import AboutHTMLView, AboutMDView from snek.view.about import AboutHTMLView, AboutMDView
@@ -59,8 +59,10 @@ from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication from snek.webdav import WebdavApplication
from snek.system.template import sanitize_html
from snek.sgit import GitApplication from snek.sgit import GitApplication
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware @web.middleware
@@ -69,6 +71,31 @@ async def session_middleware(request, handler):
response = await handler(request) response = await handler(request)
return response return response
@web.middleware
async def ip2location_middleware(request, handler):
response = await handler(request)
return response
ip = request.headers.get("X-Forwarded-For", request.remote)
ipaddress = ip_address(ip)
if ipaddress.is_private:
return response
if not request.app.session.get("uid"):
return response
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
if not user:
return response
location = request.app.ip2location.get(ip)
original_city = user['city']
if user['city'] != location.city:
user['country_long'] = location.country
user['country_short'] = locaion.country_short
user['city'] = location.city
user['region'] = location.region
user['latitude'] = location.latitude
user['longitude'] = location.longitude
user['ip'] = ip
await request.app.services.user.update(user)
return response
@web.middleware @web.middleware
async def trailing_slash_middleware(request, handler): async def trailing_slash_middleware(request, handler):
@@ -84,6 +111,8 @@ class Application(BaseApplication):
middlewares = [ middlewares = [
cors_middleware, cors_middleware,
web.normalize_path_middleware(merge_slashes=True), web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware
] ]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates") self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static") self.static_path = pathlib.Path(__file__).parent.joinpath("static")
@@ -98,6 +127,7 @@ class Application(BaseApplication):
self.jinja2_env.add_extension(LinkifyExtension) self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension) self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension) self.jinja2_env.add_extension(EmojiExtension)
self.jinja2_env.filters['sanitize'] = sanitize_html
self.time_start = datetime.now() self.time_start = datetime.now()
self.ssh_host = "0.0.0.0" self.ssh_host = "0.0.0.0"
self.ssh_port = 2242 self.ssh_port = 2242
@@ -111,11 +141,15 @@ class Application(BaseApplication):
self.broadcast_service = None self.broadcast_service = None
self.user_availability_service_task = None self.user_availability_service_task = None
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(base_path.joinpath("IP2LOCATION-LITE-DB11.BIN"))
self.on_startup.append(self.prepare_asyncio) self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database) self.on_startup.append(self.prepare_database)
@property @property
def uptime_seconds(self): def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds() return (datetime.now() - self.time_start).total_seconds()
@@ -253,9 +287,9 @@ class Application(BaseApplication):
async def handle_test(self, request): async def handle_test(self, request):
return await self.render_template( return await whitelist_attributes(self.render_template(
"test.html", request, context={"name": "retoor"} "test.html", request, context={"name": "retoor"}
) ))
async def handle_http_get(self, request: web.Request): async def handle_http_get(self, request: web.Request):
url = request.query.get("url") url = request.query.get("url")
@@ -327,6 +361,8 @@ class Application(BaseApplication):
self.jinja2_env.loader = self.original_loader self.jinja2_env.loader = self.original_loader
#rendered.text = whitelist_attributes(rendered.text)
#rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered return rendered
+8
View File
@@ -31,6 +31,14 @@ class UserModel(BaseModel):
is_admin = ModelField(name="is_admin", required=False, kind=bool) is_admin = ModelField(name="is_admin", required=False, kind=bool)
country_short = ModelField(name="country_short", required=False, kind=str)
country_long = ModelField(name="country_long", required=False, kind=str)
city = ModelField(name="city", required=False, kind=str)
latitude = ModelField(name="latitude", required=False, kind=float)
longitude = ModelField(name="longitude", required=False, kind=float)
region = ModelField(name="region", required=False, kind=str)
ip = ModelField(name="ip", required=False, kind=str)
async def get_property(self, name): async def get_property(self, name):
prop = await self.app.services.user_property.find_one( prop = await self.app.services.user_property.find_one(
user_uid=self["uid"], name=name user_uid=self["uid"], name=name
+3
View File
@@ -1,4 +1,5 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import whitelist_attributes
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
@@ -28,6 +29,7 @@ class ChannelMessageService(BaseService):
try: try:
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@@ -65,6 +67,7 @@ class ChannelMessageService(BaseService):
) )
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = template.render(**context)
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):
+9 -1
View File
@@ -174,7 +174,15 @@ export class App extends EventHandler {
await this.rpc.ping(...args); await this.rpc.ping(...args);
this.is_pinging = false; this.is_pinging = false;
} }
ntsh(times,message) {
if(!message)
message = "Nothing to see here!"
if(!times)
times=100
for(let x = 0; x < times; x++){
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
}
}
async forcePing(...arg) { async forcePing(...arg) {
await this.rpc.ping(...args); await this.rpc.ping(...args);
} }
+49
View File
@@ -221,6 +221,55 @@ footer {
hyphens: auto; hyphens: auto;
} }
.message-content .spoiler {
background-color: rgba(255, 255, 255, 0.1);
/*color: transparent;*/
cursor: pointer;
border-radius: 0.5rem;
padding: 0.5rem;
position: relative;
height: 2.5rem;
overflow: hidden;
max-width: unset;
}
.message-content .spoiler * {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active {
/*color: #e6e6e6;*/
/*transition: color 0.3s ease-in;*/
height: unset;
overflow: unset;
}
@keyframes delay-pointer-events {
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
.spoiler:hover * {
animation: unset;
}
.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * {
opacity: 1;
transition: opacity 0.3s ease-in;
pointer-events: auto;
visibility: visible;
animation: delay-pointer-events 0.2s linear;
}
.message-content { .message-content {
max-width: 100%; max-width: 100%;
} }
+127 -157
View File
@@ -2,29 +2,29 @@ import { app } from "../app.js";
class ChatInputComponent extends HTMLElement { class ChatInputComponent extends HTMLElement {
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
previousValue = "" expiryTimer = null;
lastChange = null queuedMessage = null;
changed = false lastMessagePromise = null;
constructor() { constructor() {
super(); super();
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea"); this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || ""; this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
} }
get value() { get value() {
@@ -32,24 +32,27 @@ class ChatInputComponent extends HTMLElement {
} }
set value(value) { set value(value) {
this._value = value || ""; this._value = value;
this.textarea.value = this._value; this.textarea.value = this._value;
} }
get allAutoCompletions() { get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions) return Object.assign({}, this.autoCompletions, this.hiddenCompletions)
} }
resolveAutoComplete() {
let count = 0; resolveAutoComplete(input) {
let value = null; let value = null;
Object.keys(this.allAutoCompletions).forEach((key) => { for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(this.value.split(" ")[0])) { if (key.startsWith(input.split(" ", 1)[0])) {
count++; if (value) {
return null;
}
value = key; value = key;
} }
}); }
if (count == 1) return value;
return null; return value;
} }
isActive() { isActive() {
@@ -59,26 +62,15 @@ class ChatInputComponent extends HTMLElement {
focus() { focus() {
this.textarea.focus(); this.textarea.focus();
} }
getAuthors(){
let authors = []
for (let i = 0; i < this.users.length; i++) {
authors.push(this.users[i].username)
authors.push(this.users[i].nick)
}
return authors
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick])
} }
extractMentions(text) { extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g; return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
} }
return mentions;
}
matchMentionsToAuthors(mentions, authors) { matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => { return mentions.map(mention => {
let closestAuthor = null; let closestAuthor = null;
@@ -90,7 +82,7 @@ class ChatInputComponent extends HTMLElement {
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
} }
@@ -104,8 +96,9 @@ class ChatInputComponent extends HTMLElement {
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
}); });
} }
levenshteinDistance(a, b) {
levenshteinDistance(a, b) {
const matrix = []; const matrix = [];
// Initialize the first row and column // Initialize the first row and column
@@ -132,8 +125,7 @@ levenshteinDistance(a, b) {
} }
return matrix[b.length][a.length]; return matrix[b.length][a.length];
} }
replaceMentionsWithAuthors(text) { replaceMentionsWithAuthors(text) {
@@ -148,24 +140,21 @@ levenshteinDistance(a, b) {
}); });
return updatedText; return updatedText;
} }
async connectedCallback() { async connectedCallback() {
this.user = null this.user = null
app.rpc.getUser(null).then((user) => { app.rpc.getUser(null).then((user) => {
this.user=user this.user = user
}) })
const me = this;
this.liveType = this.getAttribute("live-type") === "true"; this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval = this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6; 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;
@@ -190,90 +179,72 @@ levenshteinDistance(a, b) {
this.textarea.addEventListener("keyup", (e) => { this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
this.value = "";
const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = ""; e.target.value = "";
if (!message) {
return; return;
} }
this.value = e.target.value; let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
this.changed = true; if (autoCompletionHandler) {
this.update(); autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid)
return;
}
this.updateFromInput(e.target.value);
}); });
this.textarea.addEventListener("keydown", (e) => { this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value; this.value = e.target.value;
let autoCompletion = null; let autoCompletion = null;
if (e.key === "Tab") { if (e.key === "Tab") {
e.preventDefault(); e.preventDefault();
autoCompletion = this.resolveAutoComplete(); autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) { if (autoCompletion) {
e.target.value = autoCompletion; e.target.value = autoCompletion;
this.value = autoCompletion; this.value = autoCompletion;
return; return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
const message = me.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
return;
} }
this.updateMessage() if (e.repeat) {
app.rpc.finalizeMessage(this.messageUid) this.updateFromInput(e.target.value);
this.value = "";
this.previousValue = "";
this.messageUid = null;
} }
}); });
this.changeInterval = setInterval(() => {
if (!this.liveType) {
return;
}
if (this.value !== this.previousValue) {
if (
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval
) {
this.value = "";
this.previousValue = "";
}
this.lastChange = new Date();
}
this.update();
}, 300);
this.addEventListener("upload", (e) => { this.addEventListener("upload", (e) => {
this.focus(); this.focus();
}); });
this.addEventListener("uploaded", function (e) { this.addEventListener("uploaded", function (e) {
let message = ""; let message = e.detail.files.reduce((message, file) => {
e.detail.files.forEach((file) => { return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
message += `[${file.name}](/channel/attachment/${file.relative_url})`; }, '');
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) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime(); const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000; return millisecondsDifference / 1000;
} }
isSubsequence(s, t) { isSubsequence(s, t) {
let i = 0, j = 0; let i = 0, j = 0;
while (i < s.length && j < t.length) { while (i < s.length && j < t.length) {
@@ -285,88 +256,87 @@ levenshteinDistance(a, b) {
return i === s.length; return i === s.length;
} }
flagTyping() {
newMessage() { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
if (!this.messageUid) { this.lastUpdateEvent = new Date();
this.messageUid = "?"; app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
}
this.value = this.replaceMentionsWithAuthors(this.value);
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
if (this.liveType) {
this.messageUid = uid;
}
}); });
} }
updateMessage() {
if (this.value[0] == "/") {
return false;
}
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === "?") {
return false;
}
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
}
} }
updateStatus() { finalizeMessage(messageUid) {
if (this.liveType) { if (!messageUid) {
if (this.value.trim() === "") {
return; return;
} }
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) { this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
this.lastUpdateEvent = new Date(); } else if (messageUid.startsWith("?")) {
if ( const lastQueuedMessage = this.queuedMessage;
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.set_typing === "function"
) {
app.rpc.set_typing(this.channelUid, this.user.color);
}
}
}
update() { this.lastMessagePromise?.then((uid) => {
const expired = const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve();
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= return updatePromise.finally(() => {
this.liveTypeInterval; return app.rpc.finalizeMessage(uid);
const changed = this.value !== this.previousValue; })
})
if (changed || expired) { } else {
this.lastChange = new Date(); app.rpc.finalizeMessage(messageUid)
this.updateStatus();
} }
this.previousValue = this.value;
if (this.liveType && expired) {
this.value = ""; this.value = "";
this.previousValue = "";
this.messageUid = null; this.messageUid = null;
return; this.queuedMessage = null;
this.lastMessagePromise = null
} }
if (changed) { updateFromInput(value) {
if (this.liveType) { if (this.expiryTimer) {
this.updateMessage(); clearTimeout(this.expiryTimer);
this.expiryTimer = null;
}
this.value = value;
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
});
} }
} }
} }
async sendMessage(channelUid, value,is_final) { async sendMessage(channelUid, value, is_final) {
if (!value.trim()) { if (!value.trim()) {
return null; return null;
} }
return await app.rpc.sendMessage(channelUid, value,is_final); return await app.rpc.sendMessage(channelUid, value, is_final);
} }
} }
+9 -1
View File
@@ -23,15 +23,23 @@ class MessageList extends HTMLElement {
const messagesContainer = this const messagesContainer = this
messagesContainer.addEventListener('click', (e) => { messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return; if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target; const img = e.target;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;' overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const urlObj = new URL(img.currentSrc || img.src)
urlObj.searchParams.delete("width");
urlObj.searchParams.delete("height");
const fullImg = document.createElement('img'); const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString(); fullImg.src = urlObj.toString();
fullImg.alt = img.alt; fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%'; fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%'; fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg); overlay.appendChild(fullImg);
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay)); overlay.addEventListener('click', () => document.body.removeChild(overlay));
+8 -2
View File
@@ -12,7 +12,7 @@ class BaseMapper:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.semaphore = asyncio.Semaphore(1)
self.default_limit = self.__class__.default_limit self.default_limit = self.__class__.default_limit
@property @property
@@ -24,7 +24,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):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs)) async with self.semaphore:
return func(*args, **kwargs)
#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)
@@ -72,6 +74,10 @@ class BaseMapper:
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):
yield dict(record) yield dict(record)
async def update(self, model):
model.updated_at.update()
return await self.run_in_executor(self.table.update, model.record, ["uid"])
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.")
+3 -3
View File
@@ -14,7 +14,7 @@ from pygments.lexers import get_lexer_by_name
class MarkdownRenderer(HTMLRenderer): class MarkdownRenderer(HTMLRenderer):
_allow_harmful_protocols = True _allow_harmful_protocols = False
def __init__(self, app, template): def __init__(self, app, template):
super().__init__(False, True) super().__init__(False, True)
@@ -26,8 +26,8 @@ class MarkdownRenderer(HTMLRenderer):
formatter = html.HtmlFormatter() formatter = html.HtmlFormatter()
self.env.globals["highlight_styles"] = formatter.get_style_defs() self.env.globals["highlight_styles"] = formatter.get_style_defs()
def _escape(self, str): #def _escape(self, str):
return str ##escape(str) # return str ##escape(str)
def get_lexer(self, lang, default="bash"): def get_lexer(self, lang, default="bash"):
try: try:
+22
View File
@@ -7,8 +7,30 @@
# MIT License: This code is distributed under the MIT License. # MIT License: This code is distributed under the MIT License.
from aiohttp import web from aiohttp import web
import secrets
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):
response = await handler(request)
return response
nonce = generate_nonce()
response.headers['Content-Security-Policy'] = csp_policy.format(nonce=nonce)
return response
@web.middleware @web.middleware
async def no_cors_middleware(request, handler): async def no_cors_middleware(request, handler):
response = await handler(request) response = await handler(request)
+3
View File
@@ -26,6 +26,9 @@ class BaseService:
kwargs["uid"] = uid kwargs["uid"] = uid
return await self.count(**kwargs) > 0 return await self.count(**kwargs) > 0
async def update(self, model):
return await self.mapper.update(model)
async def count(self, **kwargs): async def count(self, **kwargs):
return await self.mapper.count(**kwargs) return await self.mapper.count(**kwargs)
+52
View File
@@ -10,6 +10,8 @@ from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
from jinja2.nodes import Const from jinja2.nodes import Const
import bleach
emoji.EMOJI_DATA['<img src="/emoji/snek1.gif" />'] = { emoji.EMOJI_DATA['<img src="/emoji/snek1.gif" />'] = {
"en": ":snek1:", "en": ":snek1:",
@@ -78,6 +80,36 @@ emoji.EMOJI_DATA[
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]} ] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
"img", "video", "audio", "source", "iframe", "picture", "span"
]
ALLOWED_ATTRIBUTES = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
"img": ["src", "alt", "title", "width", "height"],
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
"iframe": ["src", "width", "height", "frameborder", "allow", "allowfullscreen", "title", "referrerpolicy", "style"],
"video": ["src", "controls", "width", "height"],
"audio": ["src", "controls"],
"source": ["src", "type"],
"span": ["class"],
"picture": [],
}
def sanitize_html(value):
return bleach.clean(
value,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
strip=True,
)
def set_link_target_blank(text): def set_link_target_blank(text):
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")
@@ -89,6 +121,26 @@ def set_link_target_blank(text):
return str(soup) return str(soup)
SAFE_ATTRIBUTES = {
'href', 'src', 'alt', 'title', 'width', 'height', 'style', 'id', 'class',
'rel', 'type', 'name', 'value', 'placeholder', 'aria-hidden', 'aria-label', 'srcset'
}
def whitelist_attributes(html):
soup = BeautifulSoup(html, 'html.parser')
for tag in soup.find_all():
if hasattr(tag, 'attrs'):
if tag.name in ['script','form','input']:
tag.replace_with('')
continue
attrs = dict(tag.attrs)
for attr in list(attrs):
# Check if attribute is in the safe list or is a data-* attribute
if not (attr in SAFE_ATTRIBUTES or attr.startswith('data-')):
del tag.attrs[attr]
return str(soup)
def embed_youtube(text): def embed_youtube(text):
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")