Compare commits

...
Author SHA1 Message Date
retoor 57ac8e772b Update. 2025-06-06 15:02:35 +02:00
retoor 3589f42651 Update manifest. 2025-06-06 14:58:53 +02:00
retoor 1052010dd5 New sneklogo's. 2025-06-06 14:58:53 +02:00
retoor ae26181cf8 New logo's new sizes markdown stripper.py 2025-06-06 14:58:53 +02:00
retoor 66b36509d2 Merge pull request 'Remove redundant check for 'encoding' in push notification body' (#54) from BordedDev/snek:bugfix/removed-encoding into main
Reviewed-on: #54
2025-06-06 12:09:37 +02:00
BordedDev e49062a9db Remove redundant check for 'encoding' in push notification body 2025-06-06 12:06:43 +02:00
retoor 9e56ff8494 Made FA locally. 2025-06-06 11:35:56 +02:00
retoor 831b5c17cd Merge pull request 'feat/push-notifications' (#34) from BordedDev/snek:feat/push-notifications into main
Reviewed-on: #34
2025-06-06 11:25:09 +02:00
BordedDev d04ea8549d Merge branch 'main' into feat/push-notifications
# Conflicts:
#	src/snek/app.py
2025-06-06 11:21:46 +02:00
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 e380a1b9e7 Merge branch 'main' into feat/push-notifications 2025-06-05 22:06:54 +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
BordedDev 94b9d2c63b Added user check to not notify user sending message 2025-06-01 13:03:47 +02:00
BordedDev fcc2d7b748 Merge branch 'main' into feat/push-notifications
# Conflicts:
#	src/snek/app.py
2025-06-01 12:47:03 +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 0738b1ff91 Removed old comment 2025-06-01 00:56:39 +02:00
BordedDev 388f8bc508 Merge branch 'main' into feat/push-notifications 2025-06-01 00:54:17 +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
BordedDev 20dd16734f Cleaned up push register handler 2025-05-31 23:29:45 +02:00
BordedDev b01665f02c Added server (debug/testing) certs 2025-05-31 23:09:56 +02:00
BordedDev aec2da11f2 Implement push notification service and registration 2025-05-31 19:08:55 +02:00
BordedDev 272998f757 Updated conditional check 2025-05-31 15:28:54 +02:00
BordedDev 744d0ace84 Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev 326c549670 Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev d966c9529b Cleaned up code a bit 2025-05-31 15:28:52 +02:00
BordedDev 4350714534 Added body to push notifications 2025-05-31 15:26:46 +02:00
BordedDev 1a26cacb66 Fix up for push notifications on chrome 2025-05-31 15:26:45 +02:00
BordedDev 0057792802 Initial setup for push notifications (still has issues with fcm aka chrome/opera)
# Conflicts:
#	src/snek/templates/app.html

# Conflicts:
#	src/snek/app.py

# Conflicts:
#	src/snek/app.py
#	src/snek/templates/app.html

# Conflicts:
#	src/snek/app.py

# Conflicts:
#	src/snek/app.py
#	src/snek/static/push.js
#	src/snek/static/service-worker.js
#	src/snek/templates/app.html
2025-05-31 15:26:28 +02:00
46 changed files with 1104 additions and 415 deletions
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUB7PQvHZD6v8hfxeaDbU3hC0nGQQwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MDYxOTUzMDhaFw0yNjA0
MDYxOTUzMDhaMEUxCzAJBgNVBAYTAk5MMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCtYf8PP7QjRJOfK6zmfAZhSKwMowCSYijKeChxsgyn
hDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXesBBPhie+4KmtsykiI7QEHXVVrWHba
6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71Fme4ofJ2Plb7PnF53R4Tc3aTMdIW
HrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrldPHYbTGvBcDUil7qZ8hZ8ZxLMzu3
GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSyVz+fVgvLozNL9kV89hbZo7H/M37O
zmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZyXgXgdMdLAgMBAAGjUzBRMB0GA1Ud
DgQWBBQtGeiVTYjzWb2hTqJwipRVXU1LnzAfBgNVHSMEGDAWgBQtGeiVTYjzWb2h
TqJwipRVXU1LnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAc
1BacrGMlCd5nfYuvQfv0DdTVGc2FSqxPMRGrZKfjvjemgPMs0+DqUwCJiR6oEOGb
atOYoIBX9KGXSUKRYYc/N75bslwfV1CclNqd2mPxULfks/D8cAzf2mgw4kYSaDHs
tJkywBe9L6eIK4cQ5YJvutVNVKMYPi+9w+wKog/FafkamFfX/3SLCkGmV0Vv4g0q
Ro9KmTTQpJUvd63X8bONLs1t8p+HQfWmKlhuVn5+mncNdGREe8dbciXE5FKu8luN
dr/twoTZTPhmIHPmVEeNxS8hFSiu0iUPTO0HcCAODILGbtXbClA+1Z0ukiRfUya6
tgVuEk0c64L86qGP7Ply
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf
K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes
BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71
Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl
dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy
Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy
XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1
NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER
TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q
RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC
1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG
eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F
aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C
+DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz
OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl
v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB
orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs
eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s
qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F
8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC
L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC
DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E
9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv
9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45
5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3
xhhX7RsDOYMFKmn59DS1CQCZAA==
-----END PRIVATE KEY-----
+2
View File
@@ -38,6 +38,8 @@ dependencies = [
"humanize",
"Pillow",
"pillow-heif",
"IP2Location",
"bleach"
]
[tool.setuptools.packages.find]
BIN
View File
Binary file not shown.
+82 -32
View File
@@ -1,14 +1,14 @@
import asyncio
import logging
import pathlib
import time
import ssl
import uuid
from datetime import datetime
from snek import snode
from snek import snode
from snek.view.threads import ThreadsView
import json
logging.basicConfig(level=logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)
from ipaddress import ip_address
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
@@ -20,15 +20,16 @@ from aiohttp_session import (
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
import IP2Location
from snek.sssh import start_ssh_server
from snek.docs.app import Application as DocsApplication
from snek.mapper import get_mappers
from snek.service import get_services
from snek.system import http
from snek.system.cache import Cache
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.template import EmojiExtension, LinkifyExtension, PythonExtension
from snek.view.about import AboutHTMLView, AboutMDView
@@ -38,6 +39,7 @@ from snek.view.drive import DriveView
from snek.view.drive import DriveApiView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.push import PushView
from snek.view.logout import LogoutView
from snek.view.register import RegisterView
from snek.view.rpc import RPCView
@@ -57,10 +59,18 @@ from snek.view.user import UserView
from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView
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.system.template import sanitize_html
from snek.sgit import GitApplication
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware
@@ -70,6 +80,33 @@ async def session_middleware(request, handler):
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
async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"):
@@ -79,16 +116,20 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication):
def __init__(self, *args, **kwargs):
middlewares = [
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware
]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5 * args,
**kwargs,
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
self.tasks = asyncio.Queue()
@@ -98,9 +139,11 @@ class Application(BaseApplication):
self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension)
self.jinja2_env.filters['sanitize'] = sanitize_html
self.time_start = datetime.now()
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
self.setup_router()
self.ssh_server = None
self.sync_service = None
@@ -110,21 +153,24 @@ class Application(BaseApplication):
self.mappers = get_mappers(app=self)
self.broadcast_service = 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.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
@property
def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds()
@property
@property
def uptime(self):
return self._format_uptime(self.uptime_seconds)
def _format_uptime(self,seconds):
def _format_uptime(self, seconds):
seconds = int(seconds)
days, seconds = divmod(seconds, 86400)
hours, seconds = divmod(seconds, 3600)
@@ -142,14 +188,16 @@ class Application(BaseApplication):
return ", ".join(parts)
async def start_user_availability_service(self, app):
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
app.user_availability_service_task = asyncio.create_task(
app.services.socket.user_availability_service()
)
async def snode_sync(self, app):
self.sync_service = asyncio.create_task(snode.sync_service(app))
async def start_ssh_server(self, app):
app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)
app.ssh_server = await start_ssh_server(app, app.ssh_host, app.ssh_port)
if app.ssh_server:
asyncio.create_task(app.ssh_server.wait_closed())
@@ -208,6 +256,7 @@ class Application(BaseApplication):
self.router.add_view("/settings/index.html", SettingsIndexView)
self.router.add_view("/settings/profile.html", SettingsProfileView)
self.router.add_view("/settings/profile.json", SettingsProfileView)
self.router.add_view("/push.json", PushView)
self.router.add_view("/web.html", WebView)
self.router.add_view("/login.html", LoginView)
self.router.add_view("/login.json", LoginView)
@@ -247,15 +296,15 @@ class Application(BaseApplication):
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git",self.git)
#self.router.add_get("/{file_path:.*}", self.static_handler)
self.add_subapp("/git", self.git)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await self.render_template(
return await whitelist_attributes(self.render_template(
"test.html", request, context={"name": "retoor"}
)
))
async def handle_http_get(self, request: web.Request):
url = request.query.get("url")
@@ -279,9 +328,8 @@ class Application(BaseApplication):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
@@ -327,11 +375,12 @@ class Application(BaseApplication):
self.jinja2_env.loader = self.original_loader
#rendered.text = whitelist_attributes(rendered.text)
#rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered
async def static_handler(self, request):
file_name = request.match_info.get('filename', '')
file_name = request.match_info.get("filename", "")
paths = []
@@ -340,12 +389,12 @@ class Application(BaseApplication):
user_static_path = await self.services.user.get_static_path(uid)
if user_static_path:
paths.append(user_static_path)
for admin_uid in self.services.user.get_admin_uids():
user_static_path = await self.services.user.get_static_path(admin_uid)
if user_static_path:
paths.append(user_static_path)
paths.append(self.static_path)
for path in paths:
@@ -365,7 +414,6 @@ class Application(BaseApplication):
if user_template_path:
template_paths.append(user_template_path)
template_paths.append(self.template_path)
return FileSystemLoader(template_paths)
@@ -374,7 +422,9 @@ app = Application(db_path="sqlite:///snek.db")
async def main():
await web._run_app(app, port=8081, host="0.0.0.0")
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain('cert.pem', 'key.pem')
await web._run_app(app, port=8081, host="0.0.0.0", ssl_context=ssl_context)
if __name__ == "__main__":
+2
View File
@@ -9,6 +9,7 @@ from snek.mapper.notification import NotificationMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.push import PushMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.container import ContainerMapper
from snek.system.object import Object
@@ -30,6 +31,7 @@ def get_mappers(app=None):
"repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
"push": PushMapper(app=app),
}
)
+7
View File
@@ -0,0 +1,7 @@
from snek.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper
class PushMapper(BaseMapper):
model_class = PushRegistrationModel
table_name = "push_registration"
+2
View File
@@ -12,6 +12,7 @@ from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
from snek.model.repository import RepositoryModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.push_registration import PushRegistrationModel
from snek.model.container import Container
from snek.system.object import Object
@@ -31,6 +32,7 @@ def get_models():
"repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel,
"container": Container,
"push_registration": PushRegistrationModel,
}
)
+8
View File
@@ -0,0 +1,8 @@
from snek.system.model import BaseModel, ModelField
class PushRegistrationModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True)
endpoint = ModelField(name="endpoint", required=True)
key_auth = ModelField(name="key_auth", required=True)
key_p256dh = ModelField(name="key_p256dh", required=True)
+8
View File
@@ -30,6 +30,14 @@ class UserModel(BaseModel):
last_ping = ModelField(name="last_ping", required=False, kind=str)
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):
prop = await self.app.services.user_property.find_one(
+3
View File
@@ -9,6 +9,7 @@ from snek.service.drive_item import DriveItemService
from snek.service.notification import NotificationService
from snek.service.socket import SocketService
from snek.service.user import UserService
from snek.service.push import PushService
from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.service.repository import RepositoryService
@@ -17,6 +18,7 @@ from snek.service.container import ContainerService
from snek.system.object import Object
from snek.service.db import DBService
@functools.cache
def get_services(app):
return Object(
@@ -36,6 +38,7 @@ def get_services(app):
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
"push": PushService(app=app),
}
)
+3
View File
@@ -1,4 +1,5 @@
from snek.system.service import BaseService
from snek.system.template import whitelist_attributes
class ChannelMessageService(BaseService):
@@ -28,6 +29,7 @@ class ChannelMessageService(BaseService):
try:
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
except Exception as ex:
print(ex, flush=True)
@@ -65,6 +67,7 @@ class ChannelMessageService(BaseService):
)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
+17 -1
View File
@@ -1,6 +1,6 @@
from snek.system.model import now
from snek.system.service import BaseService
from snek.system.markdown import strip_markdown
class NotificationService(BaseService):
mapper_name = "notification"
@@ -62,4 +62,20 @@ class NotificationService(BaseService):
except Exception:
raise Exception(f"Failed to create notification: {model.errors}.")
if channel_member["user_uid"] != user["uid"]:
try:
stripped_message = strip_markdown(channel_message["message"])
channel_name = channel_member.get_name()
await self.app.services.push.notify_user(
user_uid=channel_member["user_uid"],
payload={
"title": f"New message in {channel_member}",
"message": f"{user['nick']}: {stripped_message}",
"icon": "/image/snek192.png",
"url": f"/channel/{channel_message['channel_uid']}.html",
},
)
except Exception as e:
print(f"Failed to send push notification:", e)
self.app.db.commit()
+271
View File
@@ -0,0 +1,271 @@
import json
import aiohttp
from snek.system.service import BaseService
import random
import time
import base64
import uuid
from functools import cache
from pathlib import Path
import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
from urllib.parse import urlparse
import os.path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# The only reason to persist the keys is to be able to use them in the web push
PRIVATE_KEY_FILE = Path("./notification-private.pem")
PRIVATE_KEY_PKCS8_FILE = Path("./notification-private.pkcs8.pem")
PUBLIC_KEY_FILE = Path("./notification-public.pem")
def generate_private_key():
if not PRIVATE_KEY_FILE.exists():
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
PRIVATE_KEY_FILE.write_bytes(pem)
def generate_pcks8_private_key():
if not PRIVATE_KEY_PKCS8_FILE.exists():
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
PRIVATE_KEY_PKCS8_FILE.write_bytes(pem)
def generate_public_key():
if not PUBLIC_KEY_FILE.exists():
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
public_key = private_key.public_key()
pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
PUBLIC_KEY_FILE.write_bytes(pem)
def ensure_certificates():
generate_private_key()
generate_pcks8_private_key()
generate_public_key()
def hkdf(input_key, salt, info, length):
return HKDF(
algorithm=SHA256(),
length=length,
salt=salt,
info=info,
backend=default_backend(),
).derive(input_key)
def _browser_base64(data):
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
class PushService(BaseService):
mapper_name = "push"
private_key_pem = None
public_key = None
public_key_base64 = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ensure_certificates()
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
self.private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
self.public_key = serialization.load_pem_public_key(
PUBLIC_KEY_FILE.read_bytes(), backend=default_backend()
)
self.public_key_base64 = _browser_base64(
self.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
def create_notification_authorization(self, push_url):
target = urlparse(push_url)
aud = f"{target.scheme}://{target.netloc}"
sub = "mailto:admin@molodetz.nl"
identifier = str(uuid.uuid4())
print(
f"Creating notification authorization for {aud} with identifier {identifier}"
)
return jwt.encode(
{
"sub": sub,
"aud": aud,
"exp": int(time.time()) + 60 * 60,
"nbf": int(time.time()),
"iat": int(time.time()),
"jti": identifier,
},
self.private_key_pem,
algorithm="ES256",
)
def create_notification_info_with_payload(
self, endpoint: str, auth: str, p256dh: str, payload: str
):
message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
message_public_key_bytes = message_private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
salt = os.urandom(16)
user_key_bytes = base64.urlsafe_b64decode(p256dh + "==")
shared_secret = message_private_key.exchange(
ec.ECDH(),
ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), user_key_bytes
),
)
encryption_key = hkdf(
shared_secret,
base64.urlsafe_b64decode(auth + "=="),
b"Content-Encoding: auth\x00",
32,
)
context = (
b"P-256\x00"
+ len(user_key_bytes).to_bytes(2, "big")
+ user_key_bytes
+ len(message_public_key_bytes).to_bytes(2, "big")
+ message_public_key_bytes
)
nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12)
content_encryption_key = hkdf(
encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16
)
padding_length = random.randint(0, 16)
padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length
data = AESGCM(content_encryption_key).encrypt(
nonce, padding + payload.encode("utf-8"), None
)
return {
"headers": {
"Authorization": f"WebPush {self.create_notification_authorization(endpoint)}",
"Crypto-Key": f"dh={_browser_base64(message_public_key_bytes)}; p256ecdsa={self.public_key_base64}",
"Encryption": f"salt={_browser_base64(salt)}",
"Content-Encoding": "aesgcm",
"Content-Length": str(len(data)),
"Content-Type": "application/octet-stream",
},
"data": data,
}
async def notify_user(self, user_uid: str, payload: dict):
async with aiohttp.ClientSession() as session:
async for subscription in self.find(user_uid=user_uid):
endpoint = subscription["endpoint"]
key_auth = subscription["key_auth"]
key_p256dh = subscription["key_p256dh"]
notification_info = self.create_notification_info_with_payload(
endpoint, key_auth, key_p256dh, json.dumps(payload)
)
headers = {
**notification_info["headers"],
"TTL": "60",
}
data = notification_info["data"]
async with session.post(
endpoint,
headers=headers,
data=data,
) as response:
if response.status == 201 or response.status == 200:
print(
f"Notification sent to user {user_uid} via endpoint {endpoint}"
)
else:
print(
f"Failed to send notification to user {user_uid} via endpoint {endpoint}: {response.status}"
)
else:
print(f"No push subscriptions found for user {user_uid}")
async def register(
self, user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
):
if await self.exists(
user_uid=user_uid,
endpoint=endpoint,
key_auth=key_auth,
key_p256dh=key_p256dh,
):
return
model = await self.new()
model["user_uid"] = user_uid
model["endpoint"] = endpoint
model["key_auth"] = key_auth
model["key_p256dh"] = key_p256dh
print(
f"Registering push subscription for user {user_uid} with endpoint {endpoint}"
)
if await self.save(model=model) and model:
print(
f"Push subscription registered for user {user_uid} with endpoint {endpoint}"
)
return model
raise Exception(
f"Failed to register push subscription for user {user_uid} with endpoint {endpoint}"
)
+9 -1
View File
@@ -174,7 +174,15 @@ export class App extends EventHandler {
await this.rpc.ping(...args);
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) {
await this.rpc.ping(...args);
}
+49
View File
@@ -221,6 +221,55 @@ footer {
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 {
max-width: 100%;
}
+213 -243
View File
@@ -2,29 +2,29 @@ import { app } from "../app.js";
class ChatInputComponent extends HTMLElement {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
"example 1": () => {
},
"example 2": () => {
},
}
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
previousValue = ""
lastChange = null
changed = false
textarea = null
_value = ""
lastUpdateEvent = null
expiryTimer = null;
queuedMessage = null;
lastMessagePromise = null;
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
get value() {
@@ -32,24 +32,27 @@ class ChatInputComponent extends HTMLElement {
}
set value(value) {
this._value = value || "";
this._value = value;
this.textarea.value = this._value;
}
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;
Object.keys(this.allAutoCompletions).forEach((key) => {
if (key.startsWith(this.value.split(" ")[0])) {
count++;
for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(input.split(" ", 1)[0])) {
if (value) {
return null;
}
value = key;
}
});
if (count == 1) return value;
return null;
}
return value;
}
isActive() {
@@ -58,117 +61,103 @@ class ChatInputComponent extends HTMLElement {
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
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
}
return mentions;
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick])
}
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
distance += 10
extractMentions(text) {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
return updatedText;
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
async connectedCallback() {
this.user = null
this.user = null
app.rpc.getUser(null).then((user) => {
this.user=user
this.user = user
})
const me = this;
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users
})
this.messageUid = null;
this.messageUid = null;
this.classList.add("chat-input");
@@ -190,183 +179,164 @@ levenshteinDistance(a, b) {
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
this.value = "";
const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid)
return;
}
this.value = e.target.value;
this.changed = true;
this.update();
this.updateFromInput(e.target.value);
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
autoCompletion = this.resolveAutoComplete();
autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
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()
app.rpc.finalizeMessage(this.messageUid)
this.value = "";
this.previousValue = "";
this.messageUid = null;
if (e.repeat) {
this.updateFromInput(e.target.value);
}
});
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.focus();
});
this.addEventListener("uploaded", function (e) {
let message = "";
e.detail.files.forEach((file) => {
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
});
app.rpc.sendMessage(this.channelUid, message,true);
let message = e.detail.files.reduce((message, file) => {
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
}, '');
app.rpc.sendMessage(this.channelUid, message, true);
});
setTimeout(()=>{
setTimeout(() => {
this.focus();
},1000)
}, 1000)
}
trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
return i === s.length;
}
newMessage() {
if (!this.messageUid) {
this.messageUid = "?";
}
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));
j++;
}
return i === s.length;
}
updateStatus() {
if (this.liveType) {
return;
}
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.set_typing === "function"
) {
app.rpc.set_typing(this.channelUid, this.user.color);
app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
});
}
}
finalizeMessage(messageUid) {
if (!messageUid) {
if (this.value.trim() === "") {
return;
}
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
} else if (messageUid.startsWith("?")) {
const lastQueuedMessage = this.queuedMessage;
this.lastMessagePromise?.then((uid) => {
const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve();
return updatePromise.finally(() => {
return app.rpc.finalizeMessage(uid);
})
})
} else {
app.rpc.finalizeMessage(messageUid)
}
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
this.lastMessagePromise = null
}
updateFromInput(value) {
if (this.expiryTimer) {
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
});
}
}
}
update() {
const expired =
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval;
const changed = this.value !== this.previousValue;
if (changed || expired) {
this.lastChange = new Date();
this.updateStatus();
}
this.previousValue = this.value;
if (this.liveType && expired) {
this.value = "";
this.previousValue = "";
this.messageUid = null;
return;
}
if (changed) {
if (this.liveType) {
this.updateMessage();
}
}
}
async sendMessage(channelUid, value,is_final) {
async sendMessage(channelUid, value, is_final) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value,is_final);
return await app.rpc.sendMessage(channelUid, value, is_final);
}
}
+9
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+57 -29
View File
@@ -1,30 +1,58 @@
{
"id": "snek",
"name": "Snek",
"description": "Danger noodle",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"theme_color": "#000000",
"background_color": "#000000",
"related_applications": [],
"prefer_related_applications": false,
"screenshots": [],
"dir": "ltr",
"lang": "en-US",
"launch_path": "/web.html",
"short_name": "Snek",
"start_url": "/web.html",
"icons": [
{
"src": "/image/snek192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
"id": "snek",
"name": "Snek",
"short_name": "Snek",
"description": "Snek Software Development Community",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/web.html",
"theme_color": "#000000",
"background_color": "#000000",
"dir": "ltr",
"lang": "en-US",
"icons": [
{
"src": "/image/snek_logo_32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "/image/snek_logo_64x64.png",
"type": "image/png",
"sizes": "64x64"
},
{
"src": "/image/snek_logo_128x128.png",
"type": "image/png",
"sizes": "128x128"
},
{
"src": "/image/snek_logo_144x144.png",
"type": "image/png",
"sizes": "144x144"
},
{
"src": "/image/snek_logo_192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek_logo_256x256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "/image/snek_logo_512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/image/snek_logo_1024x1024.png",
"type": "image/png",
"sizes": "1024x1024"
}
],
"related_applications": [],
"prefer_related_applications": false
}
+9 -1
View File
@@ -23,15 +23,23 @@ class MessageList extends HTMLElement {
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
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;'
const urlObj = new URL(img.currentSrc || img.src)
urlObj.searchParams.delete("width");
urlObj.searchParams.delete("height");
const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
+54 -31
View File
@@ -1,34 +1,57 @@
this.onpush = (event) => {
console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc.
};
export const registerServiceWorker = async (silent = false) => {
try {
const serviceWorkerRegistration = await navigator.serviceWorker
.register("/service-worker.js")
await serviceWorkerRegistration.update()
await navigator.serviceWorker.ready
const keyResponse = await fetch('/push.json')
const keyData = await keyResponse.json()
const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0))
const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true, applicationServerKey: publicKey,
})
navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => {
const subscriptionObject = {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: pushSubscription.getKey("p256dh"),
auth: pushSubscription.getKey("auth"),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings,
};
console.log(
pushSubscription.endpoint,
pushSubscription,
subscriptionObject,
);
// The push subscription details needed by the application
// server are now available, and can be sent to it using,
// for example, the fetch() API.
},
(error) => {
console.error(error);
},
);
});
console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject);
const response = await fetch('/push.json', {
method: 'POST', headers: {
'Content-Type': 'application/json',
}, body: JSON.stringify(subscriptionObject),
})
if (!response.ok) {
throw new Error('Bad status code from server.');
}
const responseData = await response.json();
console.log('Registration response', responseData);
} catch (error) {
console.error("Error registering service worker:", error);
if (!silent) {
alert("Registering push notifications failed. Please check your browser settings and try again.\n\n" + error);
}
}
}
window.registerNotificationsServiceWorker = () => {
return Notification.requestPermission().then((permission) => {
if (permission === "granted") {
console.log("Permission was granted");
return registerServiceWorker();
} else if (permission === "denied") {
console.log("Permission was denied");
} else {
console.log("Permission was dismissed");
}
});
};
registerServiceWorker(true).catch(console.error);
+55 -54
View File
@@ -1,65 +1,66 @@
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
return permission === "granted";
}
// Subscribe to Push Notifications
async function subscribeUser() {
const registration =
await navigator.serviceWorker.register("/service-worker.js");
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// Send subscription to your backend
await fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
});
}
// Service Worker (service-worker.js)
self.addEventListener("push", (event) => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.message,
icon: data.icon,
});
});
/*
self.addEventListener("install", (event) => {
console.log("Service worker installed");
console.log("Service worker installing...");
event.waitUntil(
caches.open("snek-cache").then((cache) => {
return cache.addAll([]);
})
);
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.registration?.navigationPreload.enable());
});
self.addEventListener("push", (event) => {
if (!(self.Notification && self.Notification.permission === "granted")) {
return;
}
console.log("Received a push message", event);
self.addEventListener("push", (event) => {
if (!self.Notification || self.Notification.permission !== "granted") {
console.log("Notification permission not granted");
return;
}
const data = event.data?.json() ?? {};
const title = data.title || "Something Has Happened";
const message =
data.message || "Here's something you might want to check out.";
const icon = "images/new-notification.png";
const data = event.data?.json() ?? {};
console.log("Received a push message", event, data);
event.waitUntil(self.registration.showNotification(title, {
body: message,
tag: "simple-push-demo-notification",
icon,
}));
const title = data.title || "Something Has Happened";
const message =
data.message || "Here's something you might want to check out.";
const icon = data.icon || "/image/snek512.png";
const notificationSettings = data.notificationSettings || {};
console.log("Showing message", title, message, icon);
const reg = self.registration.showNotification(title, {
body: message,
tag: "message-received",
icon,
badge: icon,
...notificationSettings,
data,
}).then(e => console.log("Showing notification", e)).catch(console.error);
event.waitUntil(reg);
});
self.addEventListener("notificationclick", (event) => {
console.log("Notification click Received.", event);
event.notification.close();
event.waitUntil(clients.openWindow(
"https://snek.molodetz.nl",));
});*/
event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`));
});
self.addEventListener("notificationclose", (event) => {
console.log("Notification closed", event);
})
self.addEventListener("fetch", (event) => {
// console.log("Fetch event for ", event.request.url);
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
// console.log("Found response in cache: ", response);
return response;
}
// console.log("No response found in cache. About to fetch from network...");
return fetch(event.request);
})
);
})
+8 -2
View File
@@ -12,7 +12,7 @@ class BaseMapper:
def __init__(self, app):
self.app = app
self.semaphore = asyncio.Semaphore(1)
self.default_limit = self.__class__.default_limit
@property
@@ -24,7 +24,9 @@ class BaseMapper:
return asyncio.get_event_loop()
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):
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):
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:
if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.")
+18 -4
View File
@@ -1,5 +1,5 @@
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
import re
from types import SimpleNamespace
from app.cache import time_cache_async
@@ -12,9 +12,23 @@ from pygments.formatters import html
from pygments.lexers import get_lexer_by_name
def strip_markdown(md_text):
# Remove code blocks (
md_text = re.sub(r'[\s\S]?```', '', md_text)
md_text = re.sub(r'^\s{4,}.$', '', md_text, flags=re.MULTILINE)
md_text = re.sub(r'^\s{0,3}#{1,6}\s+', '', md_text, flags=re.MULTILINE)
md_text = re.sub(r'!\[.?\]\(.?\)', '', md_text)
md_text = re.sub(r'\[([^\]]+)\]\(.?\)', r'\1', md_text)
md_text = re.sub(r'(\*|_){1,3}(.+?)\1{1,3}', r'\2', md_text)
md_text = re.sub(r'^\s{0,3}>+\s?', '', md_text, flags=re.MULTILINE)
md_text = re.sub(r'^(\s)(\-{3,}|_{3,}|\{3,})\s$', '', md_text, flags=re.MULTILINE)
md_text = re.sub(r'[`~>#+\-=]', '', md_text)
md_text = re.sub(r'\s+', ' ', md_text)
return md_text.strip()
class MarkdownRenderer(HTMLRenderer):
_allow_harmful_protocols = True
_allow_harmful_protocols = False
def __init__(self, app, template):
super().__init__(False, True)
@@ -26,8 +40,8 @@ class MarkdownRenderer(HTMLRenderer):
formatter = html.HtmlFormatter()
self.env.globals["highlight_styles"] = formatter.get_style_defs()
def _escape(self, str):
return str ##escape(str)
#def _escape(self, str):
# return str ##escape(str)
def get_lexer(self, lang, default="bash"):
try:
+22
View File
@@ -7,8 +7,30 @@
# MIT License: This code is distributed under the MIT License.
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
async def no_cors_middleware(request, handler):
response = await handler(request)
+3
View File
@@ -26,6 +26,9 @@ class BaseService:
kwargs["uid"] = uid
return await self.count(**kwargs) > 0
async def update(self, model):
return await self.mapper.update(model)
async def count(self, **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.ext import Extension
from jinja2.nodes import Const
import bleach
emoji.EMOJI_DATA['<img src="/emoji/snek1.gif" />'] = {
"en": ":snek1:",
@@ -78,6 +80,36 @@ emoji.EMOJI_DATA[
] = {"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):
soup = BeautifulSoup(text, "html.parser")
@@ -86,7 +118,27 @@ def set_link_target_blank(text):
element.attrs["rel"] = "noopener noreferrer"
element.attrs["referrerpolicy"] = "no-referrer"
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)
+7 -17
View File
@@ -7,9 +7,7 @@
<title>Snek</title>
<style>{{highlight_styles}}</style>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
<!--
<script src="/push.js"></script>
-->
<script src="/push.js" type="module"></script>
<script src="/fancy-button.js" type="module"></script>
<script src="/upload-button.js" type="module"></script>
<script src="/generic-form.js" type="module"></script>
@@ -21,22 +19,13 @@
<script src="/chat-input.js" type="module"></script>
<link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/fa640.all.min.css">
<link rel="stylesheet" href="/base.css">
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha512-pBMV+3tn6+5xAZuhI6tyCmQkXh15riZDqGPxAx/U+FuiI5Dh3ZTjM23cZqQ25jJCfi8+ka9gzC2ukNkGkP/Aw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="icon" type="image/png" href="/image/snek1.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">
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head>
<body>
<header>
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
@@ -46,6 +35,7 @@
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
<a class="no-select" href="/threads.html">👥</a>
<a class="no-select" href="/settings/index.html">⚙️</a>
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
<a class="no-select" href="/logout.html">đź”’</a>
</nav>
@@ -54,8 +44,8 @@
{% block sidebar %}
{% include "sidebar_channels.html" %}
{% endblock %}
<main>
<main>
{% block main %}
<chat-window class="chat-area"></chat-window>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
import base64
import json
import aiohttp
from cryptography.hazmat.primitives import serialization
from snek.system.view import BaseFormView
class PushView(BaseFormView):
async def get(self):
return await self.json_response(
{
"publicKey": base64.b64encode(
self.app.services.push.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
.decode("utf-8")
.rstrip("="),
}
)
async def post(self):
user_id = self.session.get("uid")
if user_id:
user = await self.app.services.user.get(uid=user_id)
if not user:
return await self.json_response({"error": "User not found"}, status=404)
body = await self.request.json()
if not all(
[
"endpoint" in body,
"keys" in body,
"p256dh" in body["keys"],
"auth" in body["keys"],
]
):
return await self.json_response(
{"error": "Invalid request"}, status=400
)
regist = await self.app.services.push.register(
user_uid=user_id,
endpoint=body["endpoint"],
key_auth=body["keys"]["auth"],
key_p256dh=body["keys"]["p256dh"],
)
if regist:
test_payload = {
"title": f"Welcome {user['nick']}!",
"message": "You'll now receive notifications from Snek :D",
"icon": "/image/snek192.png",
"url": "/web.html",
}
notification_info = (
self.app.services.push.create_notification_info_with_payload(
body["endpoint"],
body["keys"]["auth"],
body["keys"]["p256dh"],
json.dumps(test_payload),
)
)
headers = {
**notification_info["headers"],
"TTL": "60",
}
async with aiohttp.ClientSession() as session:
async with session.post(
body["endpoint"],
headers=headers,
data=notification_info["data"],
) as post_notification:
print(post_notification.status)
print(post_notification.text)
print(post_notification.headers)
return await self.json_response({ "registered": True })