Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ac8e772b | ||
|
|
3589f42651 | ||
|
|
1052010dd5 | ||
|
|
ae26181cf8 | ||
|
|
66b36509d2 | ||
|
|
e49062a9db | ||
|
|
9e56ff8494 | ||
|
|
831b5c17cd | ||
|
|
d04ea8549d | ||
|
|
35786703d5 | ||
|
|
10eec5fd6d | ||
|
|
d4debeab74 | ||
|
|
5c0ea360cd | ||
|
|
3b38e30df1 | ||
|
|
9a39bedd3a | ||
|
|
9e1eb9f1e5 | ||
|
|
82f8a1ef4a | ||
|
|
7c815898ea | ||
|
|
58a951eec9 | ||
|
|
ef75cb3341 | ||
|
|
1c71c0016b | ||
|
|
1a034041ab | ||
|
|
c60f9ff4d3 | ||
|
|
3efe388d3f | ||
|
|
7dc12c9e7f | ||
|
|
19c88d786e | ||
|
|
9937f532ec | ||
|
|
e380a1b9e7 | ||
|
|
13476bddf6 | ||
|
|
31d08ec973 | ||
|
|
deaa7716a2 | ||
|
|
157493b0f4 | ||
|
|
f7e1708039 | ||
|
|
20f817506f | ||
|
|
94b9d2c63b | ||
|
|
fcc2d7b748 | ||
|
|
24ddd4b294 | ||
|
|
557b34b71a | ||
|
|
e0255b28ec | ||
|
|
69855fa118 | ||
|
|
a07f2680d6 | ||
|
|
0738b1ff91 | ||
|
|
388f8bc508 | ||
|
|
a17bdc7e13 | ||
|
|
5711618e6e | ||
|
|
d022cff499 | ||
|
|
d4a480b5ea | ||
|
|
161ff392d7 | ||
|
|
4e72fbf84b | ||
|
|
20dd16734f | ||
|
|
b01665f02c | ||
|
|
aec2da11f2 | ||
|
|
272998f757 | ||
|
|
744d0ace84 | ||
|
|
326c549670 | ||
|
|
d966c9529b | ||
|
|
4350714534 | ||
|
|
1a26cacb66 | ||
|
|
0057792802 |
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -38,6 +38,8 @@ dependencies = [
|
||||
"humanize",
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
"IP2Location",
|
||||
"bleach"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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 })
|
||||