Compare commits

...
Author SHA1 Message Date
retoor e99cceaa52 Perfect. 2025-06-07 08:55:09 +02:00
retoor df8c3f1e09 Perfect. 2025-06-07 08:52:08 +02:00
retoor b65ec449a0 Update. 2025-06-07 08:31:23 +02:00
retoor bb9d763416 Update. 2025-06-07 08:27:10 +02:00
retoor bdddbf678c Update. 2025-06-07 07:56:57 +02:00
retoor 389d417c1b Update. 2025-06-07 05:47:54 +02:00
retoor f182c2209e Update. 2025-06-07 05:46:30 +02:00
retoor 8e9ee4bff0 Update. 2025-06-06 16:10:48 +02:00
retoor 7f47a21d40 Update. 2025-06-06 16:03:16 +02:00
retoor 7914511de5 Update. 2025-06-06 15:57:36 +02:00
retoor efbc6a9b4c Update. 2025-06-06 15:08:34 +02:00
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
82 changed files with 3662 additions and 1788 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.
+46 -23
View File
@@ -1,24 +1,28 @@
import click
import uvloop
from aiohttp import web
import asyncio
from snek.app import Application
from IPython import start_ipython
import sqlite3
import pathlib
import shutil
import sqlite3
import click
from aiohttp import web
from IPython import start_ipython
from snek.app import Application
@click.group()
def cli():
pass
@cli.command()
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.')
@click.option('--source',default=None, help='Database to initialize if not exists.')
def init(db_path,source):
@click.option(
"--db_path", default="snek.db", help="Database to initialize if not exists."
)
@click.option("--source", default=None, help="Database to initialize if not exists.")
def init(db_path, source):
if source and pathlib.Path(source).exists():
print(f"Copying {source} to {db_path}")
shutil.copy2(source,db_path)
shutil.copy2(source, db_path)
print("Database initialized.")
return
@@ -33,25 +37,44 @@ def init(db_path,source):
db.close()
print("Database initialized.")
@cli.command()
@click.option('--port', default=8081, show_default=True, help='Port to run the application on')
@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
def serve(port, host, db_path):
#init(db_path)
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
web.run_app(
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
)
@cli.command()
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
@click.option(
"--port", default=8081, show_default=True, help="Port to run the application on"
)
@click.option(
"--host",
default="0.0.0.0",
show_default=True,
help="Host to run the application on",
)
@click.option(
"--db_path",
default="snek.db",
show_default=True,
help="Database path for the application",
)
def serve(port, host, db_path):
# init(db_path)
# asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
web.run_app(Application(db_path=f"sqlite:///{db_path}"), port=port, host=host)
@cli.command()
@click.option(
"--db_path",
default="snek.db",
show_default=True,
help="Database path for the application",
)
def shell(db_path):
app = Application(db_path=f"sqlite:///{db_path}")
start_ipython(argv=[], user_ns={'app': app})
start_ipython(argv=[], user_ns={"app": app})
def main():
cli()
if __name__ == "__main__":
main()
+114 -42
View File
@@ -1,16 +1,18 @@
import asyncio
import logging
import pathlib
import time
import ssl
import uuid
from datetime import datetime
from snek import snode
from snek.view.threads import ThreadsView
import json
logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address
import IP2Location
from aiohttp import web
from aiohttp_session import (
get_session as session_get,
@@ -21,46 +23,58 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
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.sgit import GitApplication
from snek.sssh import start_ssh_server
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.system.template import (
EmojiExtension,
LinkifyExtension,
PythonExtension,
sanitize_html,
)
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView, ChannelView
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveView
from snek.view.drive import DriveApiView
from snek.view.drive import DriveApiView, DriveView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.logout import LogoutView
from snek.view.push import PushView
from snek.view.register import RegisterView
from snek.view.rpc import RPCView
from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView
from snek.view.settings.repositories import RepositoriesIndexView
from snek.view.settings.repositories import RepositoriesCreateView
from snek.view.settings.repositories import RepositoriesUpdateView
from snek.view.settings.repositories import RepositoriesDeleteView
from snek.view.settings.containers import (
ContainersCreateView,
ContainersDeleteView,
ContainersIndexView,
ContainersUpdateView,
)
from snek.view.settings.index import SettingsIndexView
from snek.view.settings.profile import SettingsProfileView
from snek.view.settings.repositories import (
RepositoriesCreateView,
RepositoriesDeleteView,
RepositoriesIndexView,
RepositoriesUpdateView,
)
from snek.view.stats import StatsView
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
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.webdav import WebdavApplication
from snek.sgit import GitApplication
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware
@@ -70,6 +84,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)
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 +120,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 +143,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,7 +157,10 @@ 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)
@@ -124,7 +174,7 @@ class Application(BaseApplication):
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 +192,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 +260,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)
@@ -223,8 +276,12 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
self.router.add_view(
"/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
)
self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)
self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView)
@@ -235,26 +292,40 @@ class Application(BaseApplication):
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view("/repository/{username}/{repository}/{path:.*}", RepositoryView)
self.router.add_view(
"/repository/{username}/{repository}/{path:.*}", RepositoryView
)
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
self.router.add_view(
"/settings/repositories/create.html", RepositoriesCreateView
)
self.router.add_view(
"/settings/repositories/repository/{name}/update.html",
RepositoriesUpdateView,
)
self.router.add_view(
"/settings/repositories/repository/{name}/delete.html",
RepositoriesDeleteView,
)
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
self.router.add_view(
"/settings/containers/container/{uid}/update.html", ContainersUpdateView
)
self.router.add_view(
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git",self.git)
self.add_subapp("/git", self.git)
#self.router.add_get("/{file_path:.*}", self.static_handler)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await self.render_template(
"test.html", request, context={"name": "retoor"}
return await whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
)
async def handle_http_get(self, request: web.Request):
@@ -279,7 +350,6 @@ 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 = {}
@@ -327,11 +397,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 = []
@@ -365,7 +436,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 +444,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__":
+21 -15
View File
@@ -1,6 +1,7 @@
import asyncio
import sys
class LoadBalancer:
def __init__(self, backend_ports):
self.backend_ports = backend_ports
@@ -8,27 +9,29 @@ class LoadBalancer:
self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock()
async def start_backend_servers(self,port,workers):
async def start_backend_servers(self, port, workers):
for x in range(workers):
port += 1
process = await asyncio.create_subprocess_exec(
sys.executable,
sys.argv[0],
'backend',
"backend",
str(port),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
port += 1
self.backend_processes.append(process)
print(f"Started backend server on port {(port-1)/port} with PID {process.pid}")
print(
f"Started backend server on port {(port-1)/port} with PID {process.pid}"
)
async def handle_client(self, reader, writer):
async with self.lock:
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ('127.0.0.1', self.backend_ports[server_index])
backend = ("127.0.0.1", self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
@@ -62,10 +65,10 @@ class LoadBalancer:
for i, count in enumerate(self.client_counts):
print(f"Server {self.backend_ports[i]}: {count} clients")
async def start(self, host='0.0.0.0', port=8081,workers=5):
await self.start_backend_servers(port,workers)
async def start(self, host="0.0.0.0", port=8081, workers=5):
await self.start_backend_servers(port, workers)
server = await asyncio.start_server(self.handle_client, host, port)
monitor_task = asyncio.create_task(self.monitor())
asyncio.create_task(self.monitor())
# Handle shutdown gracefully
try:
@@ -80,6 +83,7 @@ class LoadBalancer:
await asyncio.gather(*(p.wait() for p in self.backend_processes))
print("Backend processes terminated.")
async def backend_echo_server(port):
async def handle_echo(reader, writer):
try:
@@ -94,10 +98,11 @@ async def backend_echo_server(port):
finally:
writer.close()
server = await asyncio.start_server(handle_echo, '127.0.0.1', port)
server = await asyncio.start_server(handle_echo, "127.0.0.1", port)
print(f"Backend echo server running on port {port}")
await server.serve_forever()
async def main():
backend_ports = [8001, 8003, 8005, 8006]
# Launch backend echo servers
@@ -105,19 +110,20 @@ async def main():
lb = LoadBalancer(backend_ports)
await lb.start()
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == 'backend':
if sys.argv[1] == "backend":
port = int(sys.argv[2])
from snek.app import Application
snek = Application(port=port)
web.run_app(snek, port=port, host='127.0.0.1')
elif sys.argv[1] == 'sync':
from snek.sync import app
web.run_app(snek, port=port, host='127.0.0.1')
web.run_app(snek, port=port, host="127.0.0.1")
elif sys.argv[1] == "sync":
web.run_app(snek, port=port, host="127.0.0.1")
else:
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Shutting down...")
+5 -4
View File
@@ -1,21 +1,21 @@
import functools
from snek.mapper.channel import ChannelMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.channel_member import ChannelMemberMapper
from snek.mapper.channel_message import ChannelMessageMapper
from snek.mapper.container import ContainerMapper
from snek.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper
from snek.mapper.notification import NotificationMapper
from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.container import ContainerMapper
from snek.system.object import Object
@functools.cache
def get_mappers(app=None):
return Object(
**{
@@ -30,6 +30,7 @@ def get_mappers(app=None):
"repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
"push": PushMapper(app=app),
}
)
+1
View File
@@ -1,6 +1,7 @@
from snek.model.container import Container
from snek.system.mapper import BaseMapper
class ContainerMapper(BaseMapper):
model_class = Container
table_name = "container"
+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"
+5 -3
View File
@@ -1,18 +1,19 @@
import functools
from snek.model.channel import ChannelModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.channel_member import ChannelMemberModel
# from snek.model.channel_message import ChannelMessageModel
from snek.model.channel_message import ChannelMessageModel
from snek.model.container import Container
from snek.model.drive import DriveModel
from snek.model.drive_item import DriveItemModel
from snek.model.notification import NotificationModel
from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel
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.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,
}
)
+3 -4
View File
@@ -1,4 +1,3 @@
from snek.system.model import BaseModel
from snek.system.model import BaseModel, ModelField
@@ -11,6 +10,6 @@ class ChannelAttachmentModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
mime_type = ModelField(name="type", required=True, kind=str)
relative_url = ModelField(name="relative_url", required=True, kind=str)
resource_type = ModelField(name="resource_type", required=True, kind=str,value="file")
resource_type = ModelField(
name="resource_type", required=True, kind=str, value="file"
)
+8 -2
View File
@@ -1,6 +1,8 @@
from datetime import datetime, timezone
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
from datetime import datetime,timezone
class ChannelMessageModel(BaseModel):
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
@@ -10,7 +12,11 @@ class ChannelMessageModel(BaseModel):
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
return int(
(
datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])
).total_seconds()
)
async def get_user(self) -> UserModel:
return await self.app.services.user.get(uid=self["user_uid"])
+1
View File
@@ -1,5 +1,6 @@
from snek.system.model import BaseModel, ModelField
class Container(BaseModel):
id = ModelField(name="id", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
+3 -1
View File
@@ -9,7 +9,9 @@ class DriveItemModel(BaseModel):
path = ModelField(name="path", required=True, kind=str)
file_type = ModelField(name="file_type", required=True, kind=str)
file_size = ModelField(name="file_size", required=True, kind=int)
is_available = ModelField(name="is_available", required=True, kind=bool, initial_value=True)
is_available = ModelField(
name="is_available", required=True, kind=bool, initial_value=True
)
@property
def extension(self):
+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)
-4
View File
@@ -1,4 +1,3 @@
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
@@ -9,6 +8,3 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)
+8
View File
@@ -31,6 +31,14 @@ class UserModel(BaseModel):
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(
user_uid=self["uid"], name=name
+24 -21
View File
@@ -1,51 +1,54 @@
import snek.serpentarium
import time
from concurrent.futures import ProcessPoolExecutor
import snek.serpentarium
durations = []
def task1():
global durations
client = snek.serpentarium.DatasetWrapper()
start=time.time()
start = time.time()
for x in range(1500):
client['a'].delete()
client['a'].insert({"foo": x})
client['a'].find(foo=x)
client['a'].find_one(foo=x)
client['a'].count()
#print(client['a'].find(foo=x) )
#print(client['a'].find_one(foo=x) )
#print(client['a'].count())
client["a"].delete()
client["a"].insert({"foo": x})
client["a"].find(foo=x)
client["a"].find_one(foo=x)
client["a"].count()
# print(client['a'].find(foo=x) )
# print(client['a'].find_one(foo=x) )
# print(client['a'].count())
client.close()
duration1 = f"{time.time()-start}"
durations.append(duration1)
print(durations)
with ProcessPoolExecutor(max_workers=4) as executor:
tasks = [executor.submit(task1),
tasks = [
executor.submit(task1),
executor.submit(task1),
executor.submit(task1),
executor.submit(task1),
executor.submit(task1)
]
for task in tasks:
task.result()
import dataset
client = dataset.connect("sqlite:///snek.db")
start=time.time()
start = time.time()
for x in range(1500):
client['a'].delete()
client['a'].insert({"foo": x})
print([dict(row) for row in client['a'].find(foo=x)])
print(dict(client['a'].find_one(foo=x) ))
print(client['a'].count())
client["a"].delete()
client["a"].insert({"foo": x})
print([dict(row) for row in client["a"].find(foo=x)])
print(dict(client["a"].find_one(foo=x)))
print(client["a"].count())
duration2 = f"{time.time()-start}"
print(duration1,duration2)
print(duration1, duration2)
+7 -4
View File
@@ -1,21 +1,23 @@
import functools
from snek.service.channel import ChannelService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.channel_member import ChannelMemberService
from snek.service.channel_message import ChannelMessageService
from snek.service.chat import ChatService
from snek.service.container import ContainerService
from snek.service.db import DBService
from snek.service.drive import DriveService
from snek.service.drive_item import DriveItemService
from snek.service.notification import NotificationService
from snek.service.push import PushService
from snek.service.repository import RepositoryService
from snek.service.socket import SocketService
from snek.service.user import UserService
from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.service.repository import RepositoryService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.container import ContainerService
from snek.system.object import Object
from snek.service.db import DBService
@functools.cache
def get_services(app):
@@ -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),
}
)
+17 -6
View File
@@ -1,19 +1,26 @@
import pathlib
from datetime import datetime
from snek.system.model import now
from snek.system.service import BaseService
import pathlib
class ChannelService(BaseService):
mapper_name = "channel"
async def get_attachment_folder(self, channel_uid,ensure=False):
async def get_home_folder(self, channel_uid):
folder = pathlib.Path(f"./drive/{channel_uid}/container/home")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
pass
return folder
async def get_attachment_folder(self, channel_uid, ensure=False):
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
if ensure:
path.mkdir(
parents=True, exist_ok=True
)
path.mkdir(parents=True, exist_ok=True)
return path
async def get(self, uid=None, **kwargs):
@@ -56,6 +63,7 @@ class ChannelService(BaseService):
model["is_private"] = is_private
model["is_listed"] = is_listed
if await self.save(model):
await self.services.container.create(model["uid"])
return model
raise Exception(f"Failed to create channel: {model.errors}.")
@@ -68,7 +76,10 @@ class ChannelService(BaseService):
return channel
async def get_recent_users(self, channel_uid):
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
async for user in self.query(
"SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30",
{"channel_uid": channel_uid},
):
yield user
async def get_users(self, channel_uid):
+10 -10
View File
@@ -1,25 +1,25 @@
from snek.system.service import BaseService
import urllib.parse
import pathlib
import mimetypes
import uuid
from snek.system.service import BaseService
class ChannelAttachmentService(BaseService):
mapper_name="channel_attachment"
mapper_name = "channel_attachment"
async def create_file(self, channel_uid, user_uid, name):
attachment = await self.new()
attachment["channel_uid"] = channel_uid
attachment['user_uid'] = user_uid
attachment["user_uid"] = user_uid
attachment["name"] = name
attachment["mime_type"] = mimetypes.guess_type(name)[0]
attachment['resource_type'] = "file"
attachment["resource_type"] = "file"
real_file_name = f"{attachment['uid']}-{name}"
attachment["relative_url"] = (f"{attachment['uid']}-{name}")
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)
attachment["relative_url"] = f"{attachment['uid']}-{name}"
attachment_folder = await self.services.channel.get_attachment_folder(
channel_uid
)
attachment_path = attachment_folder.joinpath(real_file_name)
attachment["path"] = str(attachment_path)
if await self.save(attachment):
return attachment
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
+5 -2
View File
@@ -1,4 +1,5 @@
from snek.system.service import BaseService
from snek.system.template import whitelist_attributes
class ChannelMessageService(BaseService):
@@ -10,7 +11,7 @@ class ChannelMessageService(BaseService):
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model['is_final'] = is_final
model["is_final"] = is_final
context = {}
@@ -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)
@@ -54,7 +56,7 @@ class ChannelMessageService(BaseService):
async def save(self, model):
context = {}
context.update(model.record)
user = await self.app.services.user.get(model['user_uid'])
user = await self.app.services.user.get(model["user_uid"])
context.update(
{
"user_uid": user["uid"],
@@ -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):
+7 -5
View File
@@ -13,13 +13,13 @@ class ChatService(BaseService):
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel['uid'],
channel["uid"],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user['uid'],
"user_uid": user["uid"],
"color": user["color"],
"channel_uid": channel['uid'],
"channel_uid": channel["uid"],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
@@ -28,14 +28,16 @@ class ChatService(BaseService):
"is_final": channel_message["is_final"],
},
)
await self.app.create_task(
self.services.notification.create_channel_message(message_uid)
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message,is_final
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"]
+43 -1
View File
@@ -1,9 +1,51 @@
from snek.system.docker import ComposeFileManager
from snek.system.service import BaseService
class ContainerService(BaseService):
mapper_name = "container"
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.compose_path = "snek-container-compose.yml"
self.compose = ComposeFileManager(self.compose_path)
async def get_instances(self):
return list(self.compose.list_instances())
async def get_container_name(self, channel_uid):
return f"channel-{channel_uid}"
async def create(
self,
channel_uid,
image="ubuntu:latest",
command=None,
cpus=1,
memory="1024m",
ports=None,
volumes=None,
):
name = await self.get_container_name(channel_uid)
self.compose.create_instance(
name,
image,
command,
cpus,
memory,
ports,
[
"./"
+ str(await self.services.channel.get_home_folder(channel_uid))
+ ":"
+ "/home/ubuntu"
],
)
async def create2(
self, id, name, status, resources=None, user_uid=None, path=None, readonly=False
):
model = await self.new()
model["id"] = id
model["name"] = name
+4 -9
View File
@@ -1,8 +1,7 @@
from snek.system.service import BaseService
import dataset
import uuid
from datetime import datetime
from snek.system.service import BaseService
class DBService(BaseService):
@@ -18,7 +17,6 @@ class DBService(BaseService):
db = await self.get_db(user_uid)
return db[table_name].insert(values)
async def update(self, user_uid, table_name, values, filters):
db = await self.get_db(user_uid)
if not filters:
@@ -33,7 +31,7 @@ class DBService(BaseService):
async def find(self, user_uid, table_name, kwargs):
db = await self.get_db(user_uid)
kwargs['_limit'] = kwargs.get('_limit', 30)
kwargs["_limit"] = kwargs.get("_limit", 30)
return [dict(row) for row in db[table_name].find(**kwargs)]
async def get(self, user_uid, table_name, filters):
@@ -45,14 +43,13 @@ class DBService(BaseService):
except ValueError:
return None
async def delete(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return db[table_name].delete(**filters)
async def query(self, sql,values):
async def query(self, sql, values):
db = await self.app.db
return [dict(row) for row in db.query(sql, values or {})]
@@ -62,8 +59,6 @@ class DBService(BaseService):
filters = {}
return bool(db[table_name].find_one(**filters))
async def count(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
+19
View File
@@ -1,3 +1,4 @@
from snek.system.markdown import strip_markdown
from snek.system.model import now
from snek.system.service import BaseService
@@ -33,6 +34,8 @@ class NotificationService(BaseService):
channel_message = await self.services.channel_message.get(
uid=channel_message_uid
)
if not channel_message["is_final"]:
return
user = await self.services.user.get(uid=channel_message["user_uid"])
self.app.db.begin()
async for channel_member in self.services.channel_member.find(
@@ -62,4 +65,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 = await channel_member.get_name()
await self.app.services.push.notify_user(
user_uid=channel_member["user_uid"],
payload={
"title": f"New message in {channel_name}",
"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()
+269
View File
@@ -0,0 +1,269 @@
import base64
import json
import os.path
import random
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from snek.system.service import BaseService
# 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 -8
View File
@@ -1,13 +1,17 @@
from snek.system.service import BaseService
import asyncio
import shutil
from snek.system.service import BaseService
class RepositoryService(BaseService):
mapper_name = "repository"
async def delete(self, user_uid, name):
loop = asyncio.get_event_loop()
repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)
repository_path = (
await self.services.user.get_repository_path(user_uid)
).joinpath(name)
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
@@ -15,7 +19,6 @@ class RepositoryService(BaseService):
await super().delete(user_uid=user_uid, name=name)
async def exists(self, user_uid, name, **kwargs):
kwargs["user_uid"] = user_uid
kwargs["name"] = name
@@ -29,16 +32,14 @@ class RepositoryService(BaseService):
repository_path = str(repository_path)
if not repository_path.endswith(".git"):
repository_path += ".git"
command = ['git', 'init', '--bare', repository_path]
command = ["git", "init", "--bare", repository_path]
process = await asyncio.subprocess.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
return process.returncode == 0
async def create(self, user_uid, name,is_private=False):
async def create(self, user_uid, name, is_private=False):
if await self.exists(user_uid=user_uid, name=name):
return False
+10 -11
View File
@@ -1,12 +1,14 @@
from snek.model.user import UserModel
from snek.system.service import BaseService
from datetime import datetime
import json
import asyncio
import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
from snek.system.model import now
class SocketService(BaseService):
class Socket:
@@ -15,7 +17,6 @@ class SocketService(BaseService):
self.is_connected = True
self.user = user
async def send_json(self, data):
if not self.is_connected:
return False
@@ -41,7 +42,6 @@ class SocketService(BaseService):
self.subscriptions = {}
self.last_update = str(datetime.now())
async def user_availability_service(self):
logger.info("User availability update service started.")
while True:
@@ -50,14 +50,15 @@ class SocketService(BaseService):
for s in self.sockets:
if not s.is_connected:
continue
if not s.user in users_updated:
if s.user not in users_updated:
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
users_updated.append(s.user)
logger.info(f"Updated user availability for {len(users_updated)} online users.")
logger.info(
f"Updated user availability for {len(users_updated)} online users."
)
await asyncio.sleep(60)
async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
@@ -81,7 +82,6 @@ class SocketService(BaseService):
count += 1
return count
async def broadcast(self, channel_uid, message):
await self._broadcast(channel_uid, message)
@@ -102,4 +102,3 @@ class SocketService(BaseService):
await s.close()
logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s)
+1 -3
View File
@@ -32,7 +32,7 @@ class UserService(BaseService):
user["color"] = await self.services.util.random_light_hex_color()
return await super().save(user)
def authenticate_sync(self,username,password):
def authenticate_sync(self, username, password):
user = self.get_by_username_sync(username)
if not user:
@@ -61,8 +61,6 @@ class UserService(BaseService):
return None
return path
async def get_template_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
if not path.exists():
+231 -167
View File
@@ -1,48 +1,51 @@
import asyncio
import base64
import json
import logging
import os
import aiohttp
import shutil
import tempfile
from aiohttp import web
import shutil
import json
import tempfile
import asyncio
import logging
import base64
import pathlib
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('git_server')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("git_server")
class GitApplication(web.Application):
def __init__(self, parent=None):
#import git
#globals()['git'] = git
# import git
# globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024*1024*1024*5)
self.add_routes([
web.post('/create/{repo_name}', self.create_repository),
web.delete('/delete/{repo_name}', self.delete_repository),
web.get('/clone/{repo_name}', self.clone_repository),
web.post('/push/{repo_name}', self.push_repository),
web.post('/pull/{repo_name}', self.pull_repository),
web.get('/status/{repo_name}', self.status_repository),
#web.get('/list', self.list_repositories),
web.get('/branches/{repo_name}', self.list_branches),
web.post('/branches/{repo_name}', self.create_branch),
web.get('/log/{repo_name}', self.commit_log),
web.get('/file/{repo_name}/{file_path:.*}', self.file_content),
web.get('/{path:.+}/info/refs', self.git_smart_http),
web.post('/{path:.+}/git-upload-pack', self.git_smart_http),
web.post('/{path:.+}/git-receive-pack', self.git_smart_http),
web.get('/{repo_name}.git/info/refs', self.git_smart_http),
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),
web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),
])
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes(
[
web.post("/create/{repo_name}", self.create_repository),
web.delete("/delete/{repo_name}", self.delete_repository),
web.get("/clone/{repo_name}", self.clone_repository),
web.post("/push/{repo_name}", self.push_repository),
web.post("/pull/{repo_name}", self.pull_repository),
web.get("/status/{repo_name}", self.status_repository),
# web.get('/list', self.list_repositories),
web.get("/branches/{repo_name}", self.list_branches),
web.post("/branches/{repo_name}", self.create_branch),
web.get("/log/{repo_name}", self.commit_log),
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
web.get("/{path:.+}/info/refs", self.git_smart_http),
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None,None
return None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
@@ -50,27 +53,31 @@ class GitApplication(web.Application):
username=username, password=password
)
if not request["user"]:
return None,None
request["repository_path"] = await self.parent.services.user.get_repository_path(
request["user"]["uid"]
return None, None
request["repository_path"] = (
await self.parent.services.user.get_repository_path(request["user"]["uid"])
)
return request["user"]['username'],request["repository_path"]
return request["user"]["username"], request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path:
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')
request['username'] = username
request['repository_path'] = repository_path
return web.Response(
status=401,
headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
request["repository_path"] = repository_path
return await handler(self, request, *args, **kwargs)
return wrapped
def repo_path(self, repository_path, repo_name):
return repository_path.joinpath(repo_name + '.git')
return repository_path.joinpath(repo_name + ".git")
def check_repo_exists(self, repository_path, repo_name):
repo_dir = self.repo_path(repository_path, repo_name)
@@ -80,10 +87,10 @@ class GitApplication(web.Application):
@require_auth
async def create_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
if not repo_name or '/' in repo_name or '..' in repo_name:
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if not repo_name or "/" in repo_name or ".." in repo_name:
return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name)
if os.path.exists(repo_dir):
@@ -98,9 +105,9 @@ class GitApplication(web.Application):
@require_auth
async def delete_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -115,9 +122,9 @@ class GitApplication(web.Application):
@require_auth
async def clone_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -126,15 +133,15 @@ class GitApplication(web.Application):
response_data = {
"repository": repo_name,
"clone_command": f"git clone {clone_url}",
"clone_url": clone_url
"clone_url": clone_url,
}
return web.json_response(response_data)
@require_auth
async def push_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -142,34 +149,40 @@ class GitApplication(web.Application):
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
commit_message = data.get('commit_message', 'Update from server')
branch = data.get('branch', 'main')
changes = data.get('changes', [])
commit_message = data.get("commit_message", "Update from server")
branch = data.get("branch", "main")
changes = data.get("changes", [])
if not changes:
return web.Response(text="No changes provided", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
for change in changes:
file_path = os.path.join(temp_dir, change.get('file', ''))
content = change.get('content', '')
file_path = os.path.join(temp_dir, change.get("file", ""))
content = change.get("content", "")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
with open(file_path, "w") as f:
f.write(content)
temp_repo.git.add(A=True)
if not temp_repo.config_reader().has_section('user'):
temp_repo.config_writer().set_value("user", "name", "Git Server").release()
temp_repo.config_writer().set_value("user", "email", "git@server.local").release()
if not temp_repo.config_reader().has_section("user"):
temp_repo.config_writer().set_value(
"user", "name", "Git Server"
).release()
temp_repo.config_writer().set_value(
"user", "email", "git@server.local"
).release()
temp_repo.index.commit(commit_message)
origin = temp_repo.remote('origin')
origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -177,13 +190,15 @@ class GitApplication(web.Application):
data = await request.json()
except json.JSONDecodeError:
data = {}
remote_url = data.get('remote_url')
branch = data.get('branch', 'main')
remote_url = data.get("remote_url")
branch = data.get("branch", "main")
if not remote_url:
return web.Response(text="Remote URL is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
local_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
remote_name = "pull_source"
try:
remote = local_repo.create_remote(remote_name, remote_url)
@@ -192,38 +207,46 @@ class GitApplication(web.Application):
remote.set_url(remote_url)
remote.fetch()
local_repo.git.merge(f"{remote_name}/{branch}")
origin = local_repo.remote('origin')
origin = local_repo.remote("origin")
origin.push()
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}")
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}")
logger.info(
f"Pulled to repository {repo_name} from {remote_url} for user {username}"
)
return web.Response(
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
)
except Exception as e:
logger.error(f"Error pulling to {repo_name}: {str(e)}")
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
@require_auth
async def status_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
branches = [b.name for b in temp_repo.branches]
active_branch = temp_repo.active_branch.name
commits = []
for commit in list(temp_repo.iter_commits(max_count=5)):
commits.append({
commits.append(
{
"id": commit.hexsha,
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message
})
"message": commit.message,
}
)
files = []
for root, dirs, filenames in os.walk(temp_dir):
if '.git' in root:
if ".git" in root:
continue
for filename in filenames:
full_path = os.path.join(root, filename)
@@ -234,50 +257,58 @@ class GitApplication(web.Application):
"branches": branches,
"active_branch": active_branch,
"recent_commits": commits,
"files": files
"files": files,
}
return web.json_response(status_info)
except Exception as e:
logger.error(f"Error getting status for {repo_name}: {str(e)}")
return web.Response(text=f"Error getting repository status: {str(e)}", status=500)
return web.Response(
text=f"Error getting repository status: {str(e)}", status=500
)
@require_auth
async def list_repositories(self, request):
username = request['username']
request["username"]
try:
repos = []
user_dir = self.REPO_DIR
if os.path.exists(user_dir):
for item in os.listdir(user_dir):
item_path = os.path.join(user_dir, item)
if os.path.isdir(item_path) and item.endswith('.git'):
if os.path.isdir(item_path) and item.endswith(".git"):
repos.append(item[:-4])
if request.query.get('format') == 'json':
if request.query.get("format") == "json":
return web.json_response({"repositories": repos})
else:
return web.Response(text="\n".join(repos) if repos else "No repositories found")
return web.Response(
text="\n".join(repos) if repos else "No repositories found"
)
except Exception as e:
logger.error(f"Error listing repositories: {str(e)}")
return web.Response(text=f"Error listing repositories: {str(e)}", status=500)
return web.Response(
text=f"Error listing repositories: {str(e)}", status=500
)
@require_auth
async def list_branches(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
branches = [b.name for b in temp_repo.branches]
return web.json_response({"branches": branches})
@require_auth
async def create_branch(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -285,145 +316,168 @@ class GitApplication(web.Application):
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
branch_name = data.get('branch_name')
start_point = data.get('start_point', 'HEAD')
branch_name = data.get("branch_name")
start_point = data.get("start_point", "HEAD")
if not branch_name:
return web.Response(text="Branch name is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push('origin', branch_name)
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}")
temp_repo.git.push("origin", branch_name)
logger.info(
f"Created branch {branch_name} in repository {repo_name} for user {username}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
logger.error(f"Error creating branch {branch_name} in {repo_name}: {str(e)}")
logger.error(
f"Error creating branch {branch_name} in {repo_name}: {str(e)}"
)
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
@require_auth
async def commit_log(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
try:
limit = int(request.query.get('limit', 10))
branch = request.query.get('branch', 'main')
limit = int(request.query.get("limit", 10))
branch = request.query.get("branch", "main")
except ValueError:
return web.Response(text="Invalid limit parameter", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
commits = []
try:
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
commits.append({
commits.append(
{
"id": commit.hexsha,
"short_id": commit.hexsha[:7],
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message.strip()
})
"message": commit.message.strip(),
}
)
except git.GitCommandError as e:
if "unknown revision or path" in str(e):
commits = []
else:
raise
return web.json_response({
"repository": repo_name,
"branch": branch,
"commits": commits
})
return web.json_response(
{"repository": repo_name, "branch": branch, "commits": commits}
)
except Exception as e:
logger.error(f"Error getting commit log for {repo_name}: {str(e)}")
return web.Response(text=f"Error getting commit log: {str(e)}", status=500)
return web.Response(
text=f"Error getting commit log: {str(e)}", status=500
)
@require_auth
async def file_content(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
file_path = request.match_info.get('file_path', '')
branch = request.query.get('branch', 'main')
repository_path = request['repository_path']
request["username"]
repo_name = request.match_info["repo_name"]
file_path = request.match_info.get("file_path", "")
branch = request.query.get("branch", "main")
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
try:
temp_repo.git.checkout(branch)
except git.GitCommandError:
return web.Response(text=f"Branch '{branch}' not found", status=404)
file_full_path = os.path.join(temp_dir, file_path)
if not os.path.exists(file_full_path):
return web.Response(text=f"File '{file_path}' not found", status=404)
return web.Response(
text=f"File '{file_path}' not found", status=404
)
if os.path.isdir(file_full_path):
files = os.listdir(file_full_path)
return web.json_response({
return web.json_response(
{
"repository": repo_name,
"path": file_path,
"type": "directory",
"contents": files
})
"contents": files,
}
)
else:
try:
with open(file_full_path, 'r') as f:
with open(file_full_path) as f:
content = f.read()
return web.Response(text=content)
except UnicodeDecodeError:
return web.Response(text=f"Cannot display binary file content for '{file_path}'", status=400)
return web.Response(
text=f"Cannot display binary file content for '{file_path}'",
status=400,
)
except Exception as e:
logger.error(f"Error getting file content from {repo_name}: {str(e)}")
return web.Response(text=f"Error getting file content: {str(e)}", status=500)
return web.Response(
text=f"Error getting file content: {str(e)}", status=500
)
@require_auth
async def git_smart_http(self, request):
username = request['username']
repository_path = request['repository_path']
request["username"]
repository_path = request["repository_path"]
path = request.path
async def get_repository_path():
req_path = path.lstrip('/')
if req_path.endswith('/info/refs'):
repo_name = req_path[:-len('/info/refs')]
elif req_path.endswith('/git-upload-pack'):
repo_name = req_path[:-len('/git-upload-pack')]
elif req_path.endswith('/git-receive-pack'):
repo_name = req_path[:-len('/git-receive-pack')]
req_path = path.lstrip("/")
if req_path.endswith("/info/refs"):
repo_name = req_path[: -len("/info/refs")]
elif req_path.endswith("/git-upload-pack"):
repo_name = req_path[: -len("/git-upload-pack")]
elif req_path.endswith("/git-receive-pack"):
repo_name = req_path[: -len("/git-receive-pack")]
else:
repo_name = req_path
if repo_name.endswith('.git'):
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(f"Resolved repo path: {repo_dir}")
return repo_dir
async def handle_info_refs(service):
repo_path = await get_repository_path()
logger.info(f"handle_info_refs: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]
cmd = [service, "--stateless-rpc", "--advertise-refs", str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
return web.Response(
text=f"Git error: {stderr.decode()}", status=500
)
response = web.StreamResponse(
status=200,
reason='OK',
reason="OK",
headers={
'Content-Type': f'application/x-{service}-advertisement',
'Cache-Control': 'no-cache'
}
"Content-Type": f"application/x-{service}-advertisement",
"Cache-Control": "no-cache",
},
)
await response.prepare(request)
packet = f"# service={service}\n"
@@ -435,48 +489,58 @@ class GitApplication(web.Application):
except Exception as e:
logger.error(f"Error handling info/refs: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
async def handle_service_rpc(service):
repo_path = await get_repository_path()
logger.info(f"handle_service_rpc: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
if not request.headers.get('Content-Type') == f'application/x-{service}-request':
if (
not request.headers.get("Content-Type")
== f"application/x-{service}-request"
):
return web.Response(text="Invalid Content-Type", status=403)
body = await request.read()
cmd = [service, '--stateless-rpc', str(repo_path)]
cmd = [service, "--stateless-rpc", str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate(input=body)
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
return web.Response(
body=stdout,
content_type=f'application/x-{service}-result'
text=f"Git error: {stderr.decode()}", status=500
)
return web.Response(
body=stdout, content_type=f"application/x-{service}-result"
)
except Exception as e:
logger.error(f"Error handling service RPC: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
if request.method == 'GET' and path.endswith('/info/refs'):
service = request.query.get('service')
if service in ('git-upload-pack', 'git-receive-pack'):
if request.method == "GET" and path.endswith("/info/refs"):
service = request.query.get("service")
if service in ("git-upload-pack", "git-receive-pack"):
return await handle_info_refs(service)
else:
return web.Response(text="Smart HTTP requires service parameter", status=400)
elif request.method == 'POST' and '/git-upload-pack' in path:
return await handle_service_rpc('git-upload-pack')
elif request.method == 'POST' and '/git-receive-pack' in path:
return await handle_service_rpc('git-receive-pack')
return web.Response(
text="Smart HTTP requires service parameter", status=400
)
elif request.method == "POST" and "/git-upload-pack" in path:
return await handle_service_rpc("git-upload-pack")
elif request.method == "POST" and "/git-receive-pack" in path:
return await handle_service_rpc("git-receive-pack")
return web.Response(text="Not found", status=404)
if __name__ == '__main__':
if __name__ == "__main__":
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logger.info("Using uvloop for improved performance")
except ImportError:
+32 -23
View File
@@ -2,28 +2,28 @@ import aiohttp
ENABLED = False
import aiohttp
import asyncio
from aiohttp import web
import json
import sqlite3
import dataset
import aiohttp
from aiohttp import web
from sqlalchemy import event
from sqlalchemy.engine import Engine
import json
queue = asyncio.Queue()
class State:
do_not_sync = False
async def sync_service(app):
if not ENABLED:
return
session = aiohttp.ClientSession()
async with session.ws_connect('http://localhost:3131/ws') as ws:
async with session.ws_connect("http://localhost:3131/ws") as ws:
async def receive():
queries_synced = 0
@@ -42,20 +42,24 @@ async def sync_service(app):
except Exception as e:
print(e)
pass
#print(f"Received: {msg.data}")
# print(f"Received: {msg.data}")
elif msg.type == aiohttp.WSMsgType.ERROR:
break
async def write():
while True:
msg = await queue.get()
await ws.send_str(json.dumps(msg,default=str))
await ws.send_str(json.dumps(msg, default=str))
queue.task_done()
await asyncio.gather(receive(), write())
await session.close()
queries_queued = 0
# Attach a listener to log all executed statements
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
@@ -63,7 +67,7 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
return
global queries_queued
if State.do_not_sync:
print(statement,parameters)
print(statement, parameters)
return
if statement.startswith("SELECT"):
return
@@ -71,17 +75,18 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
queries_queued += 1
print("Queries queued: " + str(queries_queued))
async def websocket_handler(request):
queries_broadcasted = 0
ws = web.WebSocketResponse()
await ws.prepare(request)
request.app['websockets'].append(ws)
request.app["websockets"].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
for client in request.app['websockets']:
for client in request.app["websockets"]:
if client != ws:
await client.send_str(msg.data)
cursor = request.app['db'].cursor()
cursor = request.app["db"].cursor()
data = json.loads(msg.data)
queries_broadcasted += 1
@@ -89,28 +94,32 @@ async def websocket_handler(request):
cursor.close()
print("Queries broadcasted: " + str(queries_broadcasted))
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f'WebSocket connection closed with exception {ws.exception()}')
print(f"WebSocket connection closed with exception {ws.exception()}")
request.app['websockets'].remove(ws)
request.app["websockets"].remove(ws)
return ws
app = web.Application()
app['websockets'] = []
app.router.add_get('/ws', websocket_handler)
app = web.Application()
app["websockets"] = []
app.router.add_get("/ws", websocket_handler)
async def on_startup(app):
app['db'] = sqlite3.connect('snek.db')
app["db"] = sqlite3.connect("snek.db")
print("Server starting...")
async def on_cleanup(app):
for ws in app['websockets']:
for ws in app["websockets"]:
await ws.close()
app['db'].close()
app["db"].close()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
if __name__ == '__main__':
web.run_app(app, host='127.0.0.1', port=3131)
if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=3131)
+14 -9
View File
@@ -1,25 +1,30 @@
import asyncssh
import logging
from pathlib import Path
import asyncssh
global _app
def set_app(app):
global _app
_app = app
def get_app():
return _app
logger = logging.getLogger(__name__)
roots = {}
class SFTPServer(asyncssh.SFTPServer):
def __init__(self, chan: asyncssh.SSHServerChannel):
self.root = get_app().services.user.get_home_folder_by_username(
chan.get_extra_info('username')
chan.get_extra_info("username")
)
self.root.mkdir(exist_ok=True)
self.root = str(self.root)
@@ -30,17 +35,19 @@ class SFTPServer(asyncssh.SFTPServer):
logger.debug(f"Mapping client path {path} to {mapped_path}")
return str(mapped_path).encode()
class SSHServer(asyncssh.SSHServer):
def password_auth_supported(self):
return True
def validate_password(self, username, password):
logger.debug(f"Validating credentials for user {username}")
result = get_app().services.user.authenticate_sync(username,password)
result = get_app().services.user.authenticate_sync(username, password)
logger.info(f"Validating credentials for user {username}: {result}")
return result
async def start_ssh_server(app,host,port):
async def start_ssh_server(app, host, port):
set_app(app)
logger.info("Starting SFTP server setup")
@@ -63,14 +70,12 @@ async def start_ssh_server(app,host,port):
x = await asyncssh.listen(
host=host,
port=port,
#process_factory=handle_client,
# process_factory=handle_client,
server_host_keys=[key],
server_factory=SSHServer,
sftp_factory=SFTPServer
sftp_factory=SFTPServer,
)
return x
except Exception as e:
except Exception:
logger.warning(f"Failed to start SFTP server. Already running.")
pass
+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%;
}
+127 -157
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
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() {
@@ -59,26 +62,15 @@ 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
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick])
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
return mentions;
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
@@ -90,7 +82,7 @@ class ChatInputComponent extends HTMLElement {
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10
}
@@ -104,8 +96,9 @@ class ChatInputComponent extends HTMLElement {
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
@@ -132,8 +125,7 @@ levenshteinDistance(a, b) {
}
return matrix[b.length][a.length];
}
}
replaceMentionsWithAuthors(text) {
@@ -148,24 +140,21 @@ levenshteinDistance(a, b) {
});
return updatedText;
}
}
async connectedCallback() {
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=>{
app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users
})
this.messageUid = null;
@@ -190,90 +179,72 @@ 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;
}
this.value = e.target.value;
this.changed = true;
this.update();
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid)
return;
}
this.updateFromInput(e.target.value);
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
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})`;
let message = e.detail.files.reduce((message, file) => {
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
}, '');
app.rpc.sendMessage(this.channelUid, message, true);
});
app.rpc.sendMessage(this.channelUid, message,true);
});
setTimeout(()=>{
setTimeout(() => {
this.focus();
},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) {
@@ -285,88 +256,87 @@ levenshteinDistance(a, b) {
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;
}
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
});
}
updateMessage() {
if (this.value[0] == "/") {
return false;
}
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === "?") {
return false;
}
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
}
}
updateStatus() {
if (this.liveType) {
finalizeMessage(messageUid) {
if (!messageUid) {
if (this.value.trim() === "") {
return;
}
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);
}
}
}
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
} else if (messageUid.startsWith("?")) {
const lastQueuedMessage = this.queuedMessage;
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.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.previousValue = this.value;
if (this.liveType && expired) {
this.value = "";
this.previousValue = "";
this.messageUid = null;
return;
this.queuedMessage = null;
this.lastMessagePromise = null
}
if (changed) {
if (this.liveType) {
this.updateMessage();
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
});
}
}
}
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

+39 -11
View File
@@ -1,30 +1,58 @@
{
"id": "snek",
"name": "Snek",
"description": "Danger noodle",
"short_name": "Snek",
"description": "Snek Software Development Community",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/web.html",
"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",
"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/snek512.png",
"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));
+53 -30
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.
};
navigator.serviceWorker
export const registerServiceWorker = async (silent = false) => {
try {
const serviceWorkerRegistration = await navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => {
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,
})
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);
+46 -45
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")) {
if (!self.Notification || self.Notification.permission !== "granted") {
console.log("Notification permission not granted");
return;
}
console.log("Received a push message", event);
const data = event.data?.json() ?? {};
console.log("Received a push message", event, data);
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 icon = data.icon || "/image/snek512.png";
event.waitUntil(self.registration.showNotification(title, {
const notificationSettings = data.notificationSettings || {};
console.log("Showing message", title, message, icon);
const reg = self.registration.showNotification(title, {
body: message,
tag: "simple-push-demo-notification",
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);
})
);
})
+89
View File
@@ -0,0 +1,89 @@
import copy
import yaml
class ComposeFileManager:
def __init__(self, compose_path="docker-compose.yml"):
self.compose_path = compose_path
self._load()
def _load(self):
try:
with open(self.compose_path) as f:
self.compose = yaml.safe_load(f) or {}
except FileNotFoundError:
self.compose = {"version": "3", "services": {}}
def _save(self):
with open(self.compose_path, "w") as f:
yaml.dump(self.compose, f, default_flow_style=False)
def list_instances(self):
return list(self.compose.get("services", {}).keys())
def create_instance(
self,
name,
image,
command=None,
cpus=None,
memory=None,
ports=None,
volumes=None,
):
service = {
"image": image,
}
if command:
service["command"] = command
if cpus or memory:
service["deploy"] = {"resources": {"limits": {}}}
if cpus:
service["deploy"]["resources"]["limits"]["cpus"] = str(cpus)
if memory:
service["deploy"]["resources"]["limits"]["memory"] = str(memory)
if ports:
service["ports"] = [
f"{host}:{container}" for container, host in ports.items()
]
if volumes:
service["volumes"] = volumes
self.compose.setdefault("services", {})[name] = service
self._save()
def remove_instance(self, name):
if name in self.compose.get("services", {}):
del self.compose["services"][name]
self._save()
def get_instance(self, name):
return self.compose.get("services", {}).get(name)
def duplicate_instance(self, name, new_name):
orig = self.get_instance(name)
if not orig:
raise ValueError(f"No such instance: {name}")
self.compose["services"][new_name] = copy.deepcopy(orig)
self._save()
def update_instance(self, name, **kwargs):
service = self.get_instance(name)
if not service:
raise ValueError(f"No such instance: {name}")
for k, v in kwargs.items():
if v is not None:
service[k] = v
self.compose["services"][name] = service
self._save()
# Storage size is not tracked in compose files; would need Docker API for that.
# Example usage:
# mgr = ComposeFileManager()
# mgr.create_instance('web', 'nginx:latest', cpus=1, memory='512m', ports={80:8080}, volumes=['./data:/data'])
# print(mgr.list_instances())
# mgr.duplicate_instance('web', 'web_copy')
# mgr.remove_instance('web_copy')
+13 -6
View File
@@ -1,6 +1,7 @@
DEFAULT_LIMIT = 30
import typing
import asyncio
import typing
from snek.system.model import BaseModel
@@ -12,7 +13,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 +25,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)
@@ -37,7 +40,7 @@ class BaseMapper:
if uid:
kwargs["uid"] = uid
record = await self.run_in_executor(self.table.find_one,**kwargs)
record = await self.run_in_executor(self.table.find_one, **kwargs)
if not record:
return None
record = dict(record)
@@ -48,7 +51,7 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs):
return await self.run_in_executor(self.table.exists,**kwargs)
return await self.run_in_executor(self.table.exists, **kwargs)
async def count(self, **kwargs) -> int:
return await self.run_in_executor(self.table.count, **kwargs)
@@ -69,9 +72,13 @@ class BaseMapper:
yield model
async def query(self, sql, *args):
for record in await self.run_in_executor(self.db.query,sql, *args):
for record in await self.run_in_executor(self.db.query, sql, *args):
yield dict(record)
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.")
+19 -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,24 @@ 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 +41,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:
+24
View File
@@ -6,8 +6,32 @@
# MIT License: This code is distributed under the MIT License.
import secrets
from aiohttp import web
csp_policy = (
"default-src 'self'; "
"script-src 'self' https://*.cloudflare.com https://molodetz.nl 'nonce-{nonce}'; "
"style-src 'self' https://*.cloudflare.com https://molodetz.nl; "
"img-src 'self' https://*.cloudflare.com https://molodetz.nl data:; "
"connect-src 'self' https://*.cloudflare.com https://molodetz.nl;"
)
def generate_nonce():
return secrets.token_hex(16)
@web.middleware
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):
+3
View File
@@ -63,9 +63,11 @@ def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:
obj = hashlib.sha256(salted)
return obj.hexdigest()
async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
return hash_sync(data, salt)
def verify_sync(string: str, hashed: str) -> bool:
"""Verify if the given string matches the hashed value.
@@ -78,5 +80,6 @@ def verify_sync(string: str, hashed: str) -> bool:
"""
return hash_sync(string) == hashed
async def verify(string: str, hashed: str) -> bool:
return verify_sync(string, hashed)
+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)
+99 -12
View File
@@ -1,11 +1,12 @@
import mimetypes
import re
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs
from app.cache import time_cache
from urllib.parse import parse_qs, urlparse
import bleach
import emoji
import requests
from app.cache import time_cache
from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension
@@ -78,6 +79,48 @@ 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")
@@ -90,6 +133,43 @@ def set_link_target_blank(text):
return str(soup)
SAFE_ATTRIBUTES = {
"href",
"src",
"alt",
"title",
"width",
"height",
"style",
"id",
"class",
"rel",
"type",
"name",
"value",
"placeholder",
"aria-hidden",
"aria-label",
"srcset",
}
def whitelist_attributes(html):
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all():
if hasattr(tag, "attrs"):
if tag.name in ["script", "form", "input"]:
tag.replace_with("")
continue
attrs = dict(tag.attrs)
for attr in list(attrs):
# Check if attribute is in the safe list or is a data-* attribute
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
del tag.attrs[attr]
return str(soup)
def embed_youtube(text):
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
@@ -184,12 +264,12 @@ def enrich_image_rendering(text):
for element in soup.find_all("img"):
if element.attrs["src"].startswith("/"):
element.attrs["src"] += "?width=240&height=240"
picture_template = f'''
picture_template = f"""
<picture>
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
<source srcset="{element.attrs["src"]}&format=webp" type="image/webp" />
<img src="{element.attrs["src"]}&format=png" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
</picture>'''
</picture>"""
element.replace_with(BeautifulSoup(picture_template, "html.parser"))
return str(soup)
@@ -233,7 +313,7 @@ def linkify_https(text):
return set_link_target_blank(str(soup))
@time_cache(timeout=60*60)
@time_cache(timeout=60 * 60)
def get_url_content(url):
try:
response = requests.get(url, timeout=5)
@@ -316,12 +396,11 @@ def embed_url(text):
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
(
get_element_options(None, None, None, "card")
or "summary_large_image"
)
attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base
@@ -360,20 +439,28 @@ def embed_url(text):
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
BeautifulSoup(
f'<strong class="page-name">{page_name}</strong>',
"html.parser",
)
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
BeautifulSoup(
f"<p class='page-description'>{page_description or "No description available."}</p>",
"html.parser",
)
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
BeautifulSoup(
f"<p class='page-original-link'>{original_link_name}</p>",
"html.parser",
)
)
render_element.append(description_element_base)
for attachment in attachments.values():
soup.append(attachment)
+21 -18
View File
@@ -1,6 +1,6 @@
class WebSocketClient:
def __init__(self, hostname, port):
self.buffer = b''
self.buffer = b""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.hostname = hostname
self.port = port
@@ -9,14 +9,16 @@ class WebSocketClient:
def __getattr__(self, method, *args, **kwargs):
if method in self.__dict__.keys():
return self.__dict__[method]
def call(*args, **kwargs):
self.write(json.dumps({'method': method, 'args': args, 'kwargs': kwargs}))
self.write(json.dumps({"method": method, "args": args, "kwargs": kwargs}))
return json.loads(self.read())
return call
def connect(self):
self.socket.connect((self.hostname, self.port))
key = base64.b64encode(b'1234123412341234').decode('utf-8')
key = base64.b64encode(b"1234123412341234").decode("utf-8")
handshake = (
f"GET /db HTTP/1.1\r\n"
f"Host: localhost:3131\r\n"
@@ -25,28 +27,31 @@ class WebSocketClient:
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
)
self.socket.sendall(handshake.encode('utf-8'))
response = self.read_until(b'\r\n\r\n')
if b'101 Switching Protocols' not in response:
self.socket.sendall(handshake.encode("utf-8"))
response = self.read_until(b"\r\n\r\n")
if b"101 Switching Protocols" not in response:
raise Exception("Failed to connect to WebSocket")
def write(self, message):
message_bytes = message.encode('utf-8')
message_bytes = message.encode("utf-8")
length = len(message_bytes)
if length <= 125:
self.socket.sendall(b'\x81' + bytes([length]) + message_bytes)
self.socket.sendall(b"\x81" + bytes([length]) + message_bytes)
elif length >= 126 and length <= 65535:
self.socket.sendall(b'\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)
self.socket.sendall(
b"\x81" + bytes([126]) + length.to_bytes(2, "big") + message_bytes
)
else:
self.socket.sendall(b'\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)
self.socket.sendall(
b"\x81" + bytes([127]) + length.to_bytes(8, "big") + message_bytes
)
def read_until(self, delimiter):
while True:
find_pos = self.buffer.find(delimiter)
if find_pos != -1:
data = self.buffer[:find_pos+4]
self.buffer = self.buffer[find_pos+4:]
data = self.buffer[: find_pos + 4]
self.buffer = self.buffer[find_pos + 4 :]
return data
chunk = self.socket.recv(1024)
@@ -60,7 +65,7 @@ class WebSocketClient:
if not chunk:
return None
self.buffer += chunk
response = self.buffer[: length]
response = self.buffer[:length]
self.buffer = self.buffer[length:]
return response
@@ -69,13 +74,11 @@ class WebSocketClient:
frame = self.read_exactly(2)
length = frame[1] & 127
if length == 126:
length = int.from_bytes(self.read_exactly(2), 'big')
length = int.from_bytes(self.read_exactly(2), "big")
elif length == 127:
length = int.from_bytes(self.read_exactly(8), 'big')
length = int.from_bytes(self.read_exactly(8), "big")
message = self.read_exactly(length)
return message
def close(self):
self.socket.close()
+219 -9
View File
@@ -1,10 +1,220 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>About Snek Web Community</title>
<meta name="description" content="Snek is a privacy-centric web community platform engineered for reliability, security, and professional-grade design." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 100vh;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 800px; margin: auto; padding: 2rem 0; }
.about-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.about-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
margin-bottom: .5rem;
}
.about-hero img {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
}
.section p, .section ul {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
margin-bottom: 2rem;
}
.topnav .container {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 1.05rem;
padding: 0.5rem 1rem;
border-radius: 4px;
margin-right: 0.35rem;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1.1rem;
font-weight: bold;
letter-spacing: 0.02em;
}
.topnav .home-link img {
width: 28px;
height: 28px;
margin: 0 0.1em 0 0;
vertical-align: middle;
border-radius: 5px;
background: #222;
}
@media (max-width: 480px) {
.about-hero h1 { font-size: 2rem; }
.about-hero img { width: 48px; height: 48px; }
.section { padding: 1rem; }
.topnav .container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.5rem 0.7rem;
font-size: 1rem;
margin-bottom: 0.15rem;
}
.topnav .home-link img {
width: 22px; height: 22px;
}
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0 1.3rem 0;
font-size: 1.01rem;
letter-spacing: 0.01em;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 480px) {
footer {
font-size: 0.97rem;
padding: 1rem 0 0.9rem 0;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek Home Logo" />
Snek Community
</a>
</div>
</nav>
<header class="container about-hero">
<img src="/image/snek_logo_256x256.png" alt="Snek Logo" />
<h1>About Snek</h1>
<p>Snek is engineered for professionals who demand reliability, uncompromising security, and mature, clean design. This is a platform for those who expect seamless functionality and consistent results.</p>
</header>
<main class="container">
<section class="section">
<h2>What is Snek?</h2>
<p>
Snek is a privacy-driven web community and collaboration platform built with rigorous attention to reliability and operational clarity. Security, stability, and ease of use are foundational—there is no tolerance for unnecessary complexity or distractions.
</p>
<p>
Onboarding is immediate: connect via WebDAV (<strong>davs://molodetz.online/webdav</strong>) or SFTP (<strong>sftp://molodetz.online:2242</strong>) using your Snek-issued credentials. Storage policies are governed by fair-use principles and subject to moderation to ensure operational integrity.
</p>
<p>
Repository management is centralized via user settings. Message editing is purposefully disabled by default to preserve communication fidelity and accountability.
</p>
<p>
The platform leverages <strong>aiohttp</strong> for high-performance asynchronous operations, coupled with a bespoke ORM ensuring data integrity and consistency. All validation is centralized for maximum efficiency. Deployment is streamlined—single-command provisioning and full Docker support are standard. <strong>SQLite</strong> is the default database for simplicity, with <strong>PostgreSQL</strong> available for advanced requirements.
</p>
<p>
The frontend employs vanilla JavaScript exclusively, guaranteeing a lightweight, high-speed user experience with minimal maintenance overhead. Key features include real-time typing indicators, integrated web bash terminal, Markdown support, and server-side rendering for optimal performance. Snek exists to address the growing disregard for privacy and trust in mainstream platforms—here, these are non-negotiable standards.
</p>
</section>
<section class="section">
<h2>Why does Snek exist?</h2>
<p>
Leading chat platforms routinely prioritize data exploitation, restrictive ecosystems, or inflated costs. Snek takes a disciplined, user-focused approach: privacy is uncompromised, the feature set is purposeful, and superfluous elements are eliminated.
</p>
<p>
Snek was established in direct response to industry trends—RocketChats commercial pivot, Slacks proprietary and expensive model, and Teams account lock-in and administrative burden. Snek is for those who require autonomy and technical transparency.
</p>
</section>
<section class="section">
<h2>Design Principles</h2>
<ul>
<li><strong>Privacy as Policy:</strong> No email required. No invasive logging. Expedited, secure registration. Your data remains exclusively yours.</li>
<li><strong>Open Source Commitment:</strong> Complete code transparency. Deploy your own Snek instance or contribute to the ecosystem.</li>
<li><strong>Engineering Rigor:</strong> No tolerance for bloat. All code is scrutinized for efficiency and security.</li>
<li><strong>Protocol Flexibility:</strong> Native compatibility with WebDAV, SFTP, and other open standards. Vendor lock-in is categorically rejected.</li>
<li><strong>Lean Architecture:</strong> Both backend and frontend are optimized for speed and resource efficiency.</li>
</ul>
</section>
<section class="section">
<h2>How is Snek Different?</h2>
<p>
Snek is a rigorously engineered, dependable platform designed for organizations and individuals who require transparency and predictability. There is no user tracking, analytics, or marketing—ever.
</p>
<p>
Feature additions are strictly merit-based: only those that deliver measurable value and meet high standards of reliability are integrated. The focus is on sustained, long-term utility.
</p>
</section>
<section class="section">
<h2>Mission Statement</h2>
<p>
Sneks mandate is to deliver robust, private communication and collaboration tools for professionals and discerning users. Our values: unwavering privacy, adherence to open standards, and uncompromising engineering discipline. Snek serves builders, technical leaders, and anyone seeking authoritative control over their digital environment.
</p>
<p>
We uphold transparency, foster direct and thoughtful dialogue, and maintain a strictly professional atmosphere. Clear communication and constructive collaboration are non-negotiable.
</p>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Engineered for business. Secure, resilient, and built for those who demand professional-grade software.</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
{% block main %}
<div class="dialog">
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
<html-frame url="/about.md"></html-frame>
</div>
{% endblock %}
+6 -16
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,7 +44,7 @@
{% block sidebar %}
{% include "sidebar_channels.html" %}
{% endblock %}
<main>
<main>
{% block main %}
<chat-window class="chat-area"></chat-window>
+2
View File
@@ -10,6 +10,7 @@
<meta name="keywords" content="snek, chat, molodetz">
<meta name="color-scheme" content="dark">
<link rel="stylesheet" href="/sandbox.css" />
<title>{% block title %}Snek chat by Molodetz{% endblock %}</title>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
@@ -34,6 +35,7 @@
<main>
{% block main %}
{% endblock %}
{% include "sandbox.html" %}
</main>
</body>
</html>
+118 -200
View File
@@ -1,133 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: white; /* Ensure the iframe has a white background */
}
/* Base reset */
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Segoe UI',sans-serif;
background: #000;
color: #eee;
line-height:1.5;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Container */
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
/* Hero */
.hero {
text-align: center;
padding: 4rem 0;
}
.hero h1 {
font-size: 3rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
color: transparent;
}
.hero p {
font-size: 1.2rem;
margin: 1rem 0 2rem;
}
.btn {
display: inline-block;
padding: .75rem 1.5rem;
margin: .5rem;
background: #0fa;
color: #111;
font-weight: bold;
border-radius: 4px;
transition: background .2s;
}
.btn:hover { background: #7ef; }
/* Features grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.card {
background: #1a1a1a;
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
}
.card h3 {
margin-bottom: .75rem;
color: #7ef;
}
.card ul {
list-style: disc inside;
margin-top: .5rem;
}
/* Footer */
footer {
text-align: center;
font-size: .9rem;
padding: 2rem 0;
color: #888;
}
footer code {
background: #222;
padding: 2px 4px;
border-radius: 3px;
color: #7ef;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.hero h1 { font-size: 2.4rem; }
.btn { width: 100%; box-sizing: border-box; text-align:center; }
}
</style>
</head>
<body>
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Snek The Ultimate Web Community</title>
<title>Snek Professional Community for Developers, Testers & AI Professionals</title>
<meta name="description" content="Snek is a privacy-focused web platform for developers, testers, and AI professionals. Share files, collaborate on code, communicate securely, self-host, and utilize advanced AI tools—all with rapid onboarding, no email, and no logs." />
<meta name="keywords" content="Snek, developer platform, web community, testers, AI, artificial intelligence, file sharing, Git, SFTP, WebDAV, communication, collaboration, open source, self-hosting, privacy, PWA, Ubuntu terminal, web terminal, audit-free, no email" />
<meta property="og:title" content="Snek Professional Community for Developers, Testers & AI Professionals" />
<meta property="og:description" content="Join Snek: a trusted, privacy-focused platform for developers, testers, and AI professionals. Collaborate, share, and self-host with confidence." />
<meta property="og:image" content="/image/snek_logo_256x256.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://snek.community/" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Snek Professional Community for Developers, Testers & AI Professionals" />
<meta name="twitter:description" content="A privacy-focused platform for developers, testers, and AI professionals. No email, no logs—just secure collaboration and innovation." />
<meta name="twitter:image" content="/image/snek_logo_256x256.png" />
<link rel="canonical" href="https://snek.community/" />
<link rel="stylesheet" href="/sandbox.css" />
<style>
/* Base reset */
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
.card {
opacity: 0.9;
z-index: 10;
}
.star {
z-index: -10;
}
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Container */
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
/* Hero */
.hero {
text-align: center;
padding: 4rem 0;
background-image: url('/image/snek_logo_256x256.png');
background-size: contain;
background-position: left center;
background-repeat: no-repeat;
}
.hero h1 {
font-size: 3rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
margin-bottom: .5rem;
}
.hero p {
font-size: 1.2rem;
@@ -142,10 +70,9 @@
font-weight: bold;
border-radius: 4px;
transition: background .2s;
cursor: pointer;
}
.btn:hover { background: #7ef; }
/* Features grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -161,13 +88,28 @@
.card h3 {
margin-bottom: .75rem;
color: #7ef;
font-size: 1.25rem;
}
.card ul {
list-style: disc inside;
margin-top: .5rem;
}
/* Footer */
section#signup,
section#selfhost {
text-align:center;
margin:4rem 0;
}
section#selfhost pre {
display:inline-block;
background:#222;
padding:1rem;
border-radius:4px;
color:#7ef;
font-size:1rem;
margin: 1rem 0;
text-align: left;
box-sizing: border-box;
}
footer {
text-align: center;
font-size: .9rem;
@@ -180,140 +122,116 @@
border-radius: 3px;
color: #7ef;
}
/* Mobile tweaks */
.about-link {
display: inline-block;
margin: 1.5rem 0 0 0;
color: #0fa;
font-size: 1.05rem;
font-weight: bold;
text-decoration: underline;
transition: color .2s;
}
.about-link:hover {
color: #7ef;
}
@media (max-width: 480px) {
.hero h1 { font-size: 2.4rem; }
.btn { width: 100%; box-sizing: border-box; text-align:center; }
.hero {
background-size: 64px 64px;
padding-left: 0;
}
}
</style>
<link rel="stylesheet" href="/static/sandbox.css" />
</head>
<body>
<header class="container hero">
<header class="container hero">
<h1>Snek</h1>
<p>The Ultimate Web Community for Devs, Testers &amp; AI Enthusiasts</p>
<p>Professional Platform for Developers, Testers &amp; AI Professionals</p>
<a href="/login.html" class="btn">Login</a>
<a href="/register.html" class="btn">Register</a>
</header>
<main class="container">
<a href="/about.html" class="about-link">About</a>
</header>
<main class="container">
<section id="features" class="grid">
<div class="card">
<h3>File Sharing</h3>
<h3>File Management</h3>
<ul>
<li>SFTP storage with your Snek credentials</li>
<li>WebDAV support same login</li>
<li>Access secure SFTP storage with your Snek credentials</li>
<li>WebDAV integration with unified authentication</li>
</ul>
</div>
<div class="card">
<h3>Git Repositories</h3>
<h3>Git Repository Hosting</h3>
<ul>
<li>Configure repos for any official Git client</li>
<li>Instant setup, push &amp; pull</li>
<li>Configure repositories with any official Git client</li>
<li>Immediate setup for push and pull operations</li>
</ul>
</div>
<div class="card">
<h3>AI Powerhouse</h3>
<h3>AI Integration</h3>
<ul>
<li>Chat with free &amp; commercial AIs</li>
<li>Generate AI-powered images</li>
<li>Build your own AI bots in &lt;5 minutes (copy/paste example)</li>
<li>Interact with both free and commercial AI models</li>
<li>AI-driven image generation</li>
<li>Rapid custom AI bot deployment (example included)</li>
</ul>
</div>
<div class="card">
<h3>Dev &amp; Terminal</h3>
<h3>Development &amp; Terminal Access</h3>
<ul>
<li>Ubuntu web terminal in-browser</li>
<li>Full profile &amp; permissions management</li>
<li>In-browser Ubuntu terminal environment</li>
<li>Comprehensive profile and permission management</li>
</ul>
</div>
<div class="card">
<h3>Chat &amp; Media</h3>
<h3>Communication &amp; Media</h3>
<ul>
<li>Upload any file type in chat</li>
<li>Rich media support (audio, video, images…)</li>
<li>Direct messaging with other users</li>
<li>Share any file format via chat</li>
<li>Rich media playback including audio, video, and images</li>
<li>Direct messaging functionality</li>
</ul>
</div>
<div class="card">
<h3>Privacy &amp; Community</h3>
<h3>Privacy &amp; Global Community</h3>
<ul>
<li>No logging—never even your IP</li>
<li>No email required to sign up</li>
<li>Multi-national, open community</li>
<li>Hacking encouraged!</li>
<li>Zero logging—no IP or activity tracking</li>
<li>Email-free registration process</li>
<li>International, open community</li>
<li>Ethical hacking and innovation encouraged</li>
</ul>
</div>
<div class="card">
<h3>Customization &amp; Deployment</h3>
<ul>
<li>Full layout &amp; theme customization</li>
<li>Install as a PWA on your phone</li>
<li>Optionally self-host: <code>pip install snek</code>, zero config</li>
<li>Full layout and theme personalization</li>
<li>Install as a PWA for mobile and desktop</li>
<li>Self-host with <code>pip install</code>; no configuration required</li>
</ul>
</div>
</section>
<section id="signup" style="text-align:center; margin:4rem 0;">
<h2>Ready to join?</h2>
<p>No email. No logs. Just sign up, pick a username, and dive in!</p>
<a href="/register" class="btn">Sign Up Now</a>
<section id="signup">
<h2>Start Now</h2>
<p>No email. No activity logs. Register a username and access the platform immediately.</p>
<a href="/register" class="btn">Sign Up</a>
<br>
<a href="/about.html" class="about-link">Learn more about Snek</a>
</section>
<section id="selfhost" style="text-align:center; margin-bottom:4rem;">
<h2>Self-Host in Seconds</h2>
<p>Just run:</p>
<pre style="display:inline-block; background:#222; padding:1rem; border-radius:4px; color:#7ef;">
<section id="selfhost" style="margin-bottom:4rem;">
<h2>Deploy Your Own Instance</h2>
<p>Execute the following commands:</p>
<pre>
pip install git+https://retoor.molodetz.nl/retoor/snek.git
snek serve
</pre>
<p>No configuration required—it's that simple.</p>
<p>Deployment is immediate—no configuration is necessary.</p>
<a href="/about.html" class="about-link">Discover the benefits of self-hosting</a>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Join our global community of developers, testers &amp; AI enthusiasts.</p>
</footer>
<script>
// number of stars you want
const STAR_COUNT = 200;
const body = document.body;
for (let i = 0; i < STAR_COUNT; i++) {
const star = document.createElement('div');
star.classList.add('star');
// random position within the viewport
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
// random size (optional)
const size = Math.random() * 2 + 1; // between 1px and 3px
star.style.width = size + 'px';
star.style.height = size + 'px';
// random animation timing for natural flicker
const duration = Math.random() * 3 + 2; // 2s5s
const delay = Math.random() * 5; // 0s5s
star.style.animationDuration = duration + 's';
star.style.animationDelay = delay + 's';
body.appendChild(star);
}
</script>
{% include "sandbox.html" %}
</main>
<footer>
<p>&copy; 2025 Snek The premier global platform for developers, testers, and AI professionals.</p>
<a href="/about.html" class="about-link">About Snek</a>
</footer>
</body>
</html>
+14
View File
@@ -6,6 +6,20 @@
<section class="chat-area">
<message-list class="chat-messages">
{% if not messages %}
<div>
<h1>Welcome to your new channel!</h1>
<p>This is the start of something great. Use the commands below to get started:</p>
<ul>
<li>Press <code>/invite</code> to invite someone.</li>
<li>Press <code>/online</code> to see who's currently online.</li>
<li>Press <code>/help</code> to view all available commands.</li>
</ul>
<p>Enjoy chatting!</p>
</div>
{% endif %}
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
+4 -9
View File
@@ -31,21 +31,17 @@ from multiavatar import multiavatar
from snek.system.view import BaseView
from snek.view.avatar_animal import generate_avatar_with_options
import functools
class AvatarView(BaseView):
login_required = False
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.avatars = {}
async def get(self):
type_ = self.request.query.get("type")
type_match = {
"animal": self.get_animal,
"default": self.get_default
}
type_match = {"animal": self.get_animal, "default": self.get_default}
handler = type_match.get(type_, self.get_default)
return await handler()
@@ -64,10 +60,9 @@ class AvatarView(BaseView):
avatar = generate_avatar_with_options(self.request.query)
await self.app.set(key, avatar)
return web.Response(text=avatar, content_type="image/svg+xml")
except Exception as e:
except Exception:
pass
def _get(self, uid):
avatar = generate_avatar_with_options(self.request.query)
return avatar
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -31,13 +31,12 @@ from multiavatar import multiavatar
from snek.system.view import BaseView
from snek.view.avatar_animal import generate_avatar_with_options
import functools
class AvatarView(BaseView):
login_required = False
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.avatars = {}
async def get(self):
@@ -45,10 +44,9 @@ class AvatarView(BaseView):
while True:
try:
return web.Response(text=self._get(uid), content_type="image/svg+xml")
except Exception as e:
except Exception:
pass
def _get(self, uid):
if uid in self.avatars:
return self.avatars[uid]
+36 -17
View File
@@ -1,15 +1,15 @@
import asyncio
import mimetypes
from os.path import isfile
from PIL import Image
import pillow_heif.HeifImagePlugin
from snek.system.view import BaseView
import aiofiles
from aiohttp import web
import pathlib
import urllib.parse
from os.path import isfile
import aiofiles
from aiohttp import web
from PIL import Image
from snek.system.view import BaseView
class ChannelAttachmentView(BaseView):
async def get(self):
@@ -73,13 +73,16 @@ class ChannelAttachmentView(BaseView):
# response.write is async but image.save is not
naughty_steal = response.write
tasks = []
def sync_writer(*args, **kwargs):
tasks.append(naughty_steal(*args, **kwargs))
return True
setattr(response, "write", sync_writer)
image.save(response, format=format_, quality=100, optimize=True, save_all=True)
image.save(
response, format=format_, quality=100, optimize=True, save_all=True
)
setattr(response, "write", naughty_steal)
@@ -127,7 +130,9 @@ class ChannelAttachmentView(BaseView):
attachment_records = []
for attachment in attachments:
attachment_record = attachment.record
attachment_record['relative_url'] = urllib.parse.quote(attachment_record['relative_url'])
attachment_record["relative_url"] = urllib.parse.quote(
attachment_record["relative_url"]
)
attachment_records.append(attachment_record)
return web.json_response(
@@ -138,23 +143,37 @@ class ChannelAttachmentView(BaseView):
}
)
class ChannelView(BaseView):
async def get(self):
channel_name = self.request.match_info.get("channel")
if(channel_name is None):
if channel_name is None:
return web.HTTPNotFound()
channel = await self.services.channel.get(label="#" + channel_name)
if(channel is None):
if channel is None:
channel = await self.services.channel.get(label=channel_name)
channel = await self.services.channel.get(channel_name)
if(channel is None):
if channel is None:
channel = await self.services.channel.get(label=channel_name)
if(channel is None):
if channel is None:
user = await self.services.user.get(uid=self.session.get("uid"))
is_listed = self.request.query.get("listed", False) == "true"
is_private = self.request.query.get("private", False) == "true"
channel = await self.services.channel.create(label=channel_name,created_by_uid=user['uid'],description="No description provided.",tag="user",is_private=is_private,is_listed=is_listed)
channel_member = await self.services.channel_member.create(channel_uid=channel['uid'],user_uid=user['uid'],is_moderator=True,is_read_only=False,is_muted=False,is_banned=False)
channel = await self.services.channel.create(
label=channel_name,
created_by_uid=user["uid"],
description="No description provided.",
tag="user",
is_private=is_private,
is_listed=is_listed,
)
await self.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=user["uid"],
is_moderator=True,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
+65 -60
View File
@@ -1,20 +1,15 @@
import mimetypes
import os
import urllib.parse
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote
from aiohttp import web
from snek.system.view import BaseView
import os
import mimetypes
from aiohttp import web
from urllib.parse import unquote, quote
from datetime import datetime
from aiohttp import web
from pathlib import Path
import mimetypes, urllib.parse
class DriveView(BaseView):
async def get(self):
@@ -27,7 +22,7 @@ class DriveView(BaseView):
return web.HTTPNotFound(reason="Path not found")
if target.is_dir():
return await self.render_template("drive.html",{"path": rel_path})
return await self.render_template("drive.html", {"path": rel_path})
if target.is_file():
return web.FileResponse(target)
@@ -47,58 +42,54 @@ class DriveApiView(BaseView):
if target.is_dir():
entries = []
for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
for p in sorted(
target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
):
item_path = (Path(rel) / p.name).as_posix()
mime = mimetypes.guess_type(p.name)[0] if p.is_file() else "inode/directory"
url = (self.request.url.with_path(f"/drive/{urllib.parse.quote(item_path)}")
if p.is_file() else None)
entries.append({
mime = (
mimetypes.guess_type(p.name)[0]
if p.is_file()
else "inode/directory"
)
url = (
self.request.url.with_path(
f"/drive/{urllib.parse.quote(item_path)}"
)
if p.is_file()
else None
)
entries.append(
{
"name": p.name,
"type": "directory" if p.is_dir() else "file",
"mimetype": mime,
"size": p.stat().st_size if p.is_file() else None,
"path": item_path,
"url": url,
})
}
)
import json
total = len(entries)
items = entries[offset:offset+limit]
return web.json_response({
"items": json.loads(json.dumps(items,default=str)),
"pagination": {"offset": offset, "limit": limit, "total": total}
})
items = entries[offset : offset + limit]
return web.json_response(
{
"items": json.loads(json.dumps(items, default=str)),
"pagination": {"offset": offset, "limit": limit, "total": total},
}
)
url = self.request.url.with_path(f"/drive/{urllib.parse.quote(rel)}")
return web.json_response({
return web.json_response(
{
"name": target.name,
"type": "file",
"mimetype": mimetypes.guess_type(target.name)[0],
"size": target.stat().st_size,
"path": rel,
"url": str(url),
})
}
)
class DriveView222(BaseView):
@@ -124,7 +115,11 @@ class DriveView222(BaseView):
entry_path = os.path.join(dir_path, entry)
stat = os.stat(entry_path)
is_dir = os.path.isdir(entry_path)
mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or "application/octet-stream")
mimetype = (
None
if is_dir
else (mimetypes.guess_type(entry_path)[0] or "application/octet-stream")
)
size = stat.st_size if not is_dir else None
created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()
updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()
@@ -155,15 +150,20 @@ class DriveView222(BaseView):
start = (page - 1) * page_size
end = start + page_size
paged_entries = entries[start:end]
details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]
return web.json_response({
details = [
await self.entry_details(full_path, entry, rel_path)
for entry in paged_entries
]
return web.json_response(
{
"path": rel_path,
"absolute_url": abs_url,
"entries": details,
"total": len(entries),
"page": page,
"page_size": page_size,
})
}
)
else:
with open(full_path, "rb") as f:
content = f.read()
@@ -180,14 +180,18 @@ class DriveView222(BaseView):
data = await self.request.post()
if data.get("type") == "dir":
os.makedirs(full_path)
return web.json_response({"status": "created", "type": "dir", "absolute_url": abs_url})
return web.json_response(
{"status": "created", "type": "dir", "absolute_url": abs_url}
)
else:
file_field = data.get("file")
if not file_field:
raise web.HTTPBadRequest(reason="No file uploaded")
with open(full_path, "wb") as f:
f.write(file_field.file.read())
return web.json_response({"status": "created", "type": "file", "absolute_url": abs_url})
return web.json_response(
{"status": "created", "type": "file", "absolute_url": abs_url}
)
async def put(self):
rel_path = self.request.match_info.get("rel_path", "")
@@ -210,10 +214,14 @@ class DriveView222(BaseView):
raise web.HTTPNotFound(reason="Path not found")
if os.path.isdir(full_path):
os.rmdir(full_path)
return web.json_response({"status": "deleted", "type": "dir", "absolute_url": abs_url})
return web.json_response(
{"status": "deleted", "type": "dir", "absolute_url": abs_url}
)
else:
os.remove(full_path)
return web.json_response({"status": "deleted", "type": "file", "absolute_url": abs_url})
return web.json_response(
{"status": "deleted", "type": "file", "absolute_url": abs_url}
)
class DriveViewi2(BaseView):
@@ -224,19 +232,16 @@ class DriveViewi2(BaseView):
drive_uid = self.request.match_info.get("drive")
before = self.request.query.get("before")
filters = {}
if before:
filters["created_at__lt"] = before
if drive_uid:
filters['drive_uid'] = drive_uid
filters["drive_uid"] = drive_uid
drive = await self.services.drive.get(uid=drive_uid)
drive_items = []
async for item in self.services.drive_item.find(**filters):
record = item.record
record["url"] = "/drive.bin/" + record["uid"] + "." + item.extension
+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})
+70 -49
View File
@@ -1,14 +1,13 @@
import os
import asyncio
import mimetypes
import os
import urllib.parse
from pathlib import Path
import humanize
from aiohttp import web
from snek.system.view import BaseView
import asyncio
class BareRepoNavigator:
@@ -55,13 +54,17 @@ class BareRepoNavigator:
commits = []
for commit in self.repo.iter_commits(self.current_branch, max_count=count):
commits.append({
'hash': commit.hexsha,
'short_hash': commit.hexsha[:7],
'message': commit.message.strip(),
'author': commit.author.name,
'date': datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M:%S')
})
commits.append(
{
"hash": commit.hexsha,
"short_hash": commit.hexsha[:7],
"message": commit.message.strip(),
"author": commit.author.name,
"date": datetime.fromtimestamp(commit.committed_date).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
return commits
def set_commit(self, commit_hash):
@@ -77,7 +80,7 @@ class BareRepoNavigator:
def list_directory(self, path=""):
"""List the contents of a directory in the current commit."""
if not self.current_commit:
return {'dirs': [], 'files': []}
return {"dirs": [], "files": []}
dirs = []
files = []
@@ -86,26 +89,26 @@ class BareRepoNavigator:
# Get the tree at the current path
if path:
tree = self.current_commit.tree[path]
if not hasattr(tree, 'trees'): # It's a blob, not a tree
return {'dirs': [], 'files': [path]}
if not hasattr(tree, "trees"): # It's a blob, not a tree
return {"dirs": [], "files": [path]}
else:
tree = self.current_commit.tree
# List directories and files
for item in tree:
if item.type == 'tree':
if item.type == "tree":
item_path = os.path.join(path, item.name) if path else item.name
dirs.append(item_path)
elif item.type == 'blob':
elif item.type == "blob":
item_path = os.path.join(path, item.name) if path else item.name
files.append(item_path)
dirs.sort()
files.sort()
return {'dirs': dirs, 'files': files}
return {"dirs": dirs, "files": files}
except KeyError:
return {'dirs': [], 'files': []}
return {"dirs": [], "files": []}
def get_file_content(self, file_path):
"""Get the content of a file in the current commit."""
@@ -114,7 +117,7 @@ class BareRepoNavigator:
try:
blob = self.current_commit.tree[file_path]
return blob.data_stream.read().decode('utf-8', errors='replace')
return blob.data_stream.read().decode("utf-8", errors="replace")
except (KeyError, UnicodeDecodeError):
try:
# Try to get as binary if text decoding fails
@@ -145,12 +148,13 @@ class BareRepoNavigator:
return False
class RepositoryView(BaseView):
login_required = True
def checkout_bare_repo(self, bare_repo_path: Path, target_path: Path, ref: str = 'HEAD'):
def checkout_bare_repo(
self, bare_repo_path: Path, target_path: Path, ref: str = "HEAD"
):
repo = Repo(bare_repo_path)
assert repo.bare, "Repository is not bare."
@@ -163,19 +167,18 @@ class RepositoryView(BaseView):
target_file.parent.mkdir(parents=True, exist_ok=True)
print(blob.path)
with open(target_file, 'wb') as f:
with open(target_file, "wb") as f:
f.write(blob.data_stream.read())
async def get(self):
base_repo_path = Path("drive/repositories")
authenticated_user_id = self.session.get("uid")
username = self.request.match_info.get('username')
repo_name = self.request.match_info.get('repository')
rel_path = self.request.match_info.get('path', '')
username = self.request.match_info.get("username")
repo_name = self.request.match_info.get("repository")
rel_path = self.request.match_info.get("path", "")
user = None
if not username.count("-") == 4:
user = await self.app.services.user.get(username=username)
@@ -185,18 +188,20 @@ class RepositoryView(BaseView):
else:
user = await self.app.services.user.get(uid=username)
repo = await self.app.services.repository.get(name=repo_name, user_uid=user["uid"])
repo = await self.app.services.repository.get(
name=repo_name, user_uid=user["uid"]
)
if not repo:
return web.Response(text="404 Not Found", status=404)
if repo['is_private'] and authenticated_user_id != repo['uid']:
if repo["is_private"] and authenticated_user_id != repo["uid"]:
return web.Response(text="404 Not Found", status=404)
repo_root_base = (base_repo_path / user['uid'] / (repo_name + ".git")).resolve()
repo_root = (base_repo_path / user['uid'] / repo_name).resolve()
repo_root_base = (base_repo_path / user["uid"] / (repo_name + ".git")).resolve()
repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None,
self.checkout_bare_repo, repo_root_base, repo_root
await loop.run_in_executor(
None, self.checkout_bare_repo, repo_root_base, repo_root
)
except:
pass
@@ -211,24 +216,35 @@ class RepositoryView(BaseView):
return web.Response(text="404 Not Found", status=404)
if abs_path.is_dir():
return web.Response(text=self.render_directory(abs_path, username, repo_name, safe_rel_path), content_type='text/html')
return web.Response(
text=self.render_directory(
abs_path, username, repo_name, safe_rel_path
),
content_type="text/html",
)
else:
return web.Response(text=self.render_file(abs_path), content_type='text/html')
return web.Response(
text=self.render_file(abs_path), content_type="text/html"
)
def render_directory(self, abs_path, username, repo_name, safe_rel_path):
entries = sorted(abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
entries = sorted(
abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
)
items = []
if safe_rel_path:
parent_path = Path(safe_rel_path).parent
parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip('/')
parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip(
"/"
)
items.append(f'<li><a href="{parent_link}">⬅️ ..</a></li>')
for entry in entries:
link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip('/')
display = entry.name + ('/' if entry.is_dir() else '')
size = '' if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip("/")
display = entry.name + ("/" if entry.is_dir() else "")
size = "" if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
icon = self.get_icon(entry)
items.append(f'<li>{icon} <a href="{link}">{display}</a> {size}</li>')
@@ -247,19 +263,24 @@ class RepositoryView(BaseView):
def render_file(self, abs_path):
try:
with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:
with open(abs_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
return f"<pre>{content}</pre>"
except Exception as e:
return f"<h1>Error</h1><pre>{e}</pre>"
def get_icon(self, file):
if file.is_dir(): return "📁"
mime = mimetypes.guess_type(file.name)[0] or ''
if mime.startswith("image"): return "🖼️"
if mime.startswith("text"): return "📄"
if mime.startswith("audio"): return "🎵"
if mime.startswith("video"): return "🎬"
if file.name.endswith(".py"): return "🐍"
if file.is_dir():
return "📁"
mime = mimetypes.guess_type(file.name)[0] or ""
if mime.startswith("image"):
return "🖼️"
if mime.startswith("text"):
return "📄"
if mime.startswith("audio"):
return "🎵"
if mime.startswith("video"):
return "🎬"
if file.name.endswith(".py"):
return "🐍"
return "📦"
+66 -40
View File
@@ -7,19 +7,20 @@
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
import json
import traceback
import asyncio
import json
import logging
import traceback
from aiohttp import web
from snek.system.model import now
from snek.system.profiler import Profiler
from snek.system.view import BaseView
import logging
logger = logging.getLogger(__name__)
class RPCView(BaseView):
class RPCApi:
def __init__(self, view, ws):
@@ -32,7 +33,7 @@ class RPCView(BaseView):
async def _session_ensure(self):
uid = await self.view.session_get("uid")
if not uid in self.user_session:
if uid not in self.user_session:
self.user_session[uid] = {
"said_hello": False,
}
@@ -50,45 +51,54 @@ class RPCView(BaseView):
self._require_login()
return await self.services.db.insert(self.user_uid, table_name, record)
async def db_update(self, table_name, record):
self._require_login()
return await self.services.db.update(self.user_uid, table_name, record)
async def set_typing(self,channel_uid,color=None):
async def set_typing(self, channel_uid, color=None):
self._require_login()
user = await self.services.user.get(self.user_uid)
if not color:
color = user["color"]
return await self.services.socket.broadcast(channel_uid, {
return await self.services.socket.broadcast(
channel_uid,
{
"channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2",
"event": "set_typing",
"data": {
"event":"set_typing",
"user_uid": user['uid'],
"event": "set_typing",
"user_uid": user["uid"],
"username": user["username"],
"nick": user["nick"],
"channel_uid": channel_uid,
"color": color
}
})
"color": color,
},
},
)
async def db_delete(self, table_name, record):
self._require_login()
return await self.services.db.delete(self.user_uid, table_name, record)
async def db_get(self, table_name, record):
self._require_login()
return await self.services.db.get(self.user_uid, table_name, record)
async def db_find(self, table_name, record):
self._require_login()
return await self.services.db.find(self.user_uid, table_name, record)
async def db_upsert(self, table_name, record,keys):
async def db_upsert(self, table_name, record, keys):
self._require_login()
return await self.services.db.upsert(self.user_uid, table_name, record,keys)
return await self.services.db.upsert(
self.user_uid, table_name, record, keys
)
async def db_query(self, table_name, args):
self._require_login()
return await self.services.db.query(self.user_uid, table_name, sql, args)
@property
def user_uid(self):
return self.view.session.get("uid")
@@ -202,13 +212,12 @@ class RPCView(BaseView):
if message["user_uid"] != self.user_uid:
raise Exception("Not allowed")
if not message['is_final']:
await self.services.chat.finalize(message['uid'])
if not message["is_final"]:
await self.services.chat.finalize(message["uid"])
return True
async def update_message_text(self,message_uid, text):
async def update_message_text(self, message_uid, text):
self._require_login()
message = await self.services.channel_message.get(message_uid)
if message["user_uid"] != self.user_uid:
@@ -216,30 +225,39 @@ class RPCView(BaseView):
if message.get_seconds_since_last_update() > 3:
await self.finalize_message(message["uid"])
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
return {
"error": "Message too old",
"seconds_since_last_update": message.get_seconds_since_last_update(),
"success": False,
}
message['message'] = text
message["message"] = text
if not text:
message['deleted_at'] = now()
message["deleted_at"] = now()
else:
message['deleted_at'] = None
message["deleted_at"] = None
await self.services.channel_message.save(message)
data = message.record
data['text'] = message["message"]
data['message_uid'] = message_uid
data["text"] = message["message"]
data["message_uid"] = message_uid
await self.services.socket.broadcast(message["channel_uid"], {
await self.services.socket.broadcast(
message["channel_uid"],
{
"channel_uid": message["channel_uid"],
"event": "update_message_text",
"data": message.record
})
"data": message.record,
},
)
return {"success": True}
async def send_message(self, channel_uid, message,is_final=True):
async def send_message(self, channel_uid, message, is_final=True):
self._require_login()
message = await self.services.chat.send(self.user_uid, channel_uid, message,is_final)
message = await self.services.chat.send(
self.user_uid, channel_uid, message, is_final
)
return message["uid"]
@@ -330,7 +348,8 @@ class RPCView(BaseView):
self._require_login()
results = [
record async for record in self.services.channel.get_recent_users(channel_uid)
record
async for record in self.services.channel.get_recent_users(channel_uid)
]
results = sorted(results, key=lambda x: x["nick"])
return results
@@ -371,7 +390,6 @@ class RPCView(BaseView):
await self.services.socket.send_to_user(self.user_uid, call)
self._scheduled.remove(call)
async def ping(self, callId, *args):
if self.user_uid:
user = await self.services.user.get(uid=self.user_uid)
@@ -379,17 +397,23 @@ class RPCView(BaseView):
await self.services.user.save(user)
return {"pong": args}
async def stars_render(self, channel_uid, message):
for user in await self.get_online_users(channel_uid):
try:
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
await self.services.socket.send_to_user(
user["uid"],
{
"event": "stars_render",
"data": {"channel_uid": channel_uid, "message": message},
},
)
except Exception as ex:
print(ex)
async def get(self):
scheduled = []
async def schedule(uid, seconds, call):
scheduled.append(call)
await asyncio.sleep(seconds)
@@ -409,13 +433,15 @@ class RPCView(BaseView):
ws, subscription["channel_uid"], self.request.session.get("uid")
)
if not scheduled and self.request.app.uptime_seconds < 5:
await schedule(self.request.session.get("uid"),0,{"event":"refresh", "data": {
"message": "Finishing deployment"}
}
await schedule(
self.request.session.get("uid"),
0,
{"event": "refresh", "data": {"message": "Finishing deployment"}},
)
await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
"uptime": self.request.app.uptime}
}
await schedule(
self.request.session.get("uid"),
15,
{"event": "deployed", "data": {"uptime": self.request.app.uptime}},
)
rpc = RPCView.RPCApi(self, ws)
+24 -18
View File
@@ -1,8 +1,7 @@
import asyncio
from aiohttp import web
from snek.system.view import BaseFormView
import pathlib
class ContainersIndexView(BaseFormView):
@@ -18,7 +17,10 @@ class ContainersIndexView(BaseFormView):
user = await self.services.user.get(uid=self.session.get("uid"))
return await self.render_template("settings/containers/index.html", {"containers": containers, "user": user})
return await self.render_template(
"settings/containers/index.html", {"containers": containers, "user": user}
)
class ContainersCreateView(BaseFormView):
@@ -30,16 +32,17 @@ class ContainersCreateView(BaseFormView):
async def post(self):
data = await self.request.post()
container = await self.services.container.create(
await self.services.container.create(
user_uid=self.session.get("uid"),
name=data['name'],
status=data['status'],
resources=data.get('resources', ''),
path=data.get('path', ''),
readonly=bool(data.get('readonly', False))
name=data["name"],
status=data["status"],
resources=data.get("resources", ""),
path=data.get("path", ""),
readonly=bool(data.get("readonly", False)),
)
return web.HTTPFound("/settings/containers/index.html")
class ContainersUpdateView(BaseFormView):
login_required = True
@@ -51,20 +54,23 @@ class ContainersUpdateView(BaseFormView):
)
if not container:
return web.HTTPNotFound()
return await self.render_template("settings/containers/update.html", {"container": container.record})
return await self.render_template(
"settings/containers/update.html", {"container": container.record}
)
async def post(self):
data = await self.request.post()
container = await self.services.container.get(
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
)
container['status'] = data['status']
container['resources'] = data.get('resources', '')
container['path'] = data.get('path', '')
container['readonly'] = bool(data.get('readonly', False))
container["status"] = data["status"]
container["resources"] = data.get("resources", "")
container["path"] = data.get("path", "")
container["readonly"] = bool(data.get("readonly", False))
await self.services.container.save(container)
return web.HTTPFound("/settings/containers/index.html")
class ContainersDeleteView(BaseFormView):
login_required = True
@@ -77,14 +83,14 @@ class ContainersDeleteView(BaseFormView):
if not container:
return web.HTTPNotFound()
return await self.render_template("settings/containers/delete.html", {"container": container.record})
return await self.render_template(
"settings/containers/delete.html", {"container": container.record}
)
async def post(self):
user_uid = self.session.get("uid")
uid = self.request.match_info["uid"]
container = await self.services.container.get(
user_uid=user_uid, uid=uid
)
container = await self.services.container.get(user_uid=user_uid, uid=uid)
if not container:
return web.HTTPNotFound()
await self.services.container.delete(user_uid=user_uid, uid=uid)
+20 -14
View File
@@ -1,8 +1,7 @@
import asyncio
from aiohttp import web
from snek.system.view import BaseFormView
import pathlib
class RepositoriesIndexView(BaseFormView):
@@ -18,9 +17,10 @@ class RepositoriesIndexView(BaseFormView):
user = await self.services.user.get(uid=self.session.get("uid"))
return await self.render_template("settings/repositories/index.html", {"repositories": repositories, "user": user})
return await self.render_template(
"settings/repositories/index.html",
{"repositories": repositories, "user": user},
)
class RepositoriesCreateView(BaseFormView):
@@ -33,9 +33,14 @@ class RepositoriesCreateView(BaseFormView):
async def post(self):
data = await self.request.post()
repository = await self.services.repository.create(user_uid=self.session.get("uid"), name=data['name'], is_private=int(data.get('is_private',0)))
await self.services.repository.create(
user_uid=self.session.get("uid"),
name=data["name"],
is_private=int(data.get("is_private", 0)),
)
return web.HTTPFound("/settings/repositories/index.html")
class RepositoriesUpdateView(BaseFormView):
login_required = True
@@ -47,17 +52,20 @@ class RepositoriesUpdateView(BaseFormView):
)
if not repository:
return web.HTTPNotFound()
return await self.render_template("settings/repositories/update.html", {"repository": repository.record})
return await self.render_template(
"settings/repositories/update.html", {"repository": repository.record}
)
async def post(self):
data = await self.request.post()
repository = await self.services.repository.get(
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
)
repository['is_private'] = int(data.get('is_private',0))
repository["is_private"] = int(data.get("is_private", 0))
await self.services.repository.save(repository)
return web.HTTPFound("/settings/repositories/index.html")
class RepositoriesDeleteView(BaseFormView):
login_required = True
@@ -70,17 +78,15 @@ class RepositoriesDeleteView(BaseFormView):
if not repository:
return web.HTTPNotFound()
return await self.render_template("settings/repositories/delete.html", {"repository": repository.record})
return await self.render_template(
"settings/repositories/delete.html", {"repository": repository.record}
)
async def post(self):
user_uid = self.session.get("uid")
name = self.request.match_info["name"]
repository = await self.services.repository.get(
user_uid=user_uid, name=name
)
repository = await self.services.repository.get(user_uid=user_uid, name=name)
if not repository:
return web.HTTPNotFound()
await self.services.repository.delete(user_uid=user_uid, name=name)
return web.HTTPFound("/settings/repositories/index.html")