Merge branch 'main' into feat/push-notifications

# Conflicts:
#	src/snek/app.py
This commit is contained in:
BordedDev 2025-06-06 11:21:46 +02:00
commit d04ea8549d
No known key found for this signature in database
GPG Key ID: C5F495EAE56673BF
9 changed files with 196 additions and 20 deletions

View File

@ -39,6 +39,7 @@ dependencies = [
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location", "IP2Location",
"bleach"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@ -29,7 +29,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
@ -66,9 +66,11 @@ from snek.view.settings.containers import (
ContainersDeleteView, 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
@ -119,6 +121,7 @@ class Application(BaseApplication):
cors_middleware, cors_middleware,
web.normalize_path_middleware(merge_slashes=True), web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware, 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")
@ -136,6 +139,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
@ -297,9 +301,10 @@ class Application(BaseApplication):
# self.router.add_get("/{file_path:.*}", self.static_handler) # self.router.add_get("/{file_path:.*}", self.static_handler)
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")
@ -370,6 +375,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
async def static_handler(self, request): async def static_handler(self, request):

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

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

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%;
} }

View File

@ -17,6 +17,8 @@ class ChatInputComponent extends HTMLElement {
_value = "" _value = ""
lastUpdateEvent = null lastUpdateEvent = null
expiryTimer = null; expiryTimer = null;
queuedMessage = null;
lastMessagePromise = null;
constructor() { constructor() {
super(); super();
@ -38,11 +40,11 @@ class ChatInputComponent extends HTMLElement {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions) return Object.assign({}, this.autoCompletions, this.hiddenCompletions)
} }
resolveAutoComplete() { resolveAutoComplete(input) {
let value = null; let value = null;
for (const key of Object.keys(this.allAutoCompletions)) { for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(this.value.split(" ", 1)[0])) { if (key.startsWith(input.split(" ", 1)[0])) {
if (value) { if (value) {
return null; return null;
} }
@ -193,7 +195,7 @@ class ChatInputComponent extends HTMLElement {
return; return;
} }
this.finalizeMessage() this.finalizeMessage(this.messageUid)
return; return;
} }
@ -203,19 +205,25 @@ class ChatInputComponent extends HTMLElement {
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();
} }
if (e.repeat) {
this.updateFromInput(e.target.value);
}
}); });
this.addEventListener("upload", (e) => { this.addEventListener("upload", (e) => {
@ -256,17 +264,28 @@ class ChatInputComponent extends HTMLElement {
} }
} }
finalizeMessage() { finalizeMessage(messageUid) {
if (!this.messageUid) { if (!messageUid) {
if (this.value.trim() === "") { if (this.value.trim() === "") {
return; return;
} }
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType); 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 { } else {
app.rpc.finalizeMessage(this.messageUid) app.rpc.finalizeMessage(messageUid)
} }
this.value = ""; this.value = "";
this.messageUid = null; this.messageUid = null;
this.queuedMessage = null;
this.lastMessagePromise = null
} }
updateFromInput(value) { updateFromInput(value) {
@ -281,18 +300,33 @@ class ChatInputComponent extends HTMLElement {
if (this.liveType && value[0] !== "/") { if (this.liveType && value[0] !== "/") {
this.expiryTimer = setTimeout(() => { this.expiryTimer = setTimeout(() => {
this.finalizeMessage() this.finalizeMessage(this.messageUid)
}, this.liveTypeInterval * 1000); }, this.liveTypeInterval * 1000);
if (this.messageUid === "?") {
const messageText = this.replaceMentionsWithAuthors(value);
if (this.messageUid?.startsWith("?")) {
this.queuedMessage = messageText;
} else if (this.messageUid) { } else if (this.messageUid) {
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value)); app.rpc.updateMessageText(this.messageUid, messageText).then((d) => {
if (!d.success) {
this.messageUid = null
this.updateFromInput(value)
}
})
} else { } else {
this.messageUid = "?"; // Indicate that a message is being sent const placeHolderId = "?" + crypto.randomUUID();
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(value), !this.liveType).then((uid) => { this.messageUid = placeHolderId;
if (this.liveType) {
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; this.messageUid = uid;
} }
return uid
}); });
} }
} }

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:

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)

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")
@ -86,7 +118,27 @@ def set_link_target_blank(text):
element.attrs["rel"] = "noopener noreferrer" element.attrs["rel"] = "noopener noreferrer"
element.attrs["referrerpolicy"] = "no-referrer" element.attrs["referrerpolicy"] = "no-referrer"
element.attrs["href"] = element.attrs["href"].strip(".").strip(",") element.attrs["href"] = element.attrs["href"].strip(".").strip(",")
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) return str(soup)