Compare commits
No commits in common. "main" and "feat/make-database-async" have entirely different histories.
main
...
feat/make-
2
.gitignore
vendored
@ -1,5 +1,3 @@
|
||||
|
||||
snek-container-compose.yml
|
||||
.r_history
|
||||
.vscode
|
||||
.history
|
||||
|
@ -10,9 +10,3 @@ RUN chmod +x r
|
||||
|
||||
RUN mv r /usr/local/bin/r
|
||||
|
||||
RUN echo 'root:root' | chpasswd
|
||||
|
||||
COPY ./terminal /opt/bootstrap
|
||||
COPY ./terminal /opt/snek
|
||||
RUN cp -r /root /opt/bootstrap/root
|
||||
COPY ./terminal/entry /usr/local/bin/entry
|
||||
|
5
Makefile
@ -20,10 +20,7 @@ serve: run
|
||||
|
||||
run:
|
||||
.venv/bin/snek serve
|
||||
|
||||
maintenance:
|
||||
.venv/bin/snek maintenance
|
||||
|
||||
|
||||
install: ubuntu
|
||||
python3.12 -m venv .venv
|
||||
$(PIP) install -e .
|
||||
|
21
cert.pem
@ -1,21 +0,0 @@
|
||||
-----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
key.pem
@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf
|
||||
K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes
|
||||
BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71
|
||||
Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl
|
||||
dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy
|
||||
Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy
|
||||
XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1
|
||||
NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER
|
||||
TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q
|
||||
RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC
|
||||
1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG
|
||||
eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F
|
||||
aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C
|
||||
+DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz
|
||||
OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl
|
||||
v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB
|
||||
orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs
|
||||
eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s
|
||||
qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F
|
||||
8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC
|
||||
L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC
|
||||
DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E
|
||||
9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv
|
||||
9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45
|
||||
5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3
|
||||
xhhX7RsDOYMFKmn59DS1CQCZAA==
|
||||
-----END PRIVATE KEY-----
|
@ -38,10 +38,6 @@ dependencies = [
|
||||
"humanize",
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
"IP2Location",
|
||||
"bleach",
|
||||
"sentry-sdk",
|
||||
"aiosqlite"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
@ -1,68 +1 @@
|
||||
"""
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 retoor
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Author: retoor <retoor@molodetz.nl>
|
||||
Description: Utility to load environment variables from a .env file.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def load_env(file_path: str = ".env") -> None:
|
||||
"""
|
||||
Loads environment variables from a specified file into the current process environment.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the environment file. Defaults to '.env'.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the specified file does not exist.
|
||||
IOError: If an I/O error occurs during file reading.
|
||||
"""
|
||||
try:
|
||||
with open(file_path) as env_file:
|
||||
for line in env_file:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Skip lines without '='
|
||||
if "=" not in line:
|
||||
continue
|
||||
# Split into key and value at the first '='
|
||||
key, value = line.split("=", 1)
|
||||
# Set environment variable
|
||||
os.environ[key.strip()] = value.strip()
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Environment file '{file_path}' not found.")
|
||||
except OSError as e:
|
||||
raise OSError(f"Error reading environment file '{file_path}': {e}")
|
||||
|
||||
|
||||
try:
|
||||
load_env()
|
||||
except Exception:
|
||||
pass
|
||||
|
@ -1,90 +1,27 @@
|
||||
import asyncio
|
||||
import pathlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
|
||||
import click
|
||||
import uvloop
|
||||
from aiohttp import web
|
||||
|
||||
import asyncio
|
||||
from snek.app import Application
|
||||
from snek.shell import Shell
|
||||
|
||||
from IPython import start_ipython
|
||||
import sqlite3
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
def export():
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
|
||||
async def fix_message(message):
|
||||
message = {
|
||||
"uid": message["uid"],
|
||||
"user_uid": message["user_uid"],
|
||||
"text": message["message"],
|
||||
"sent": message["created_at"],
|
||||
}
|
||||
user = await app.services.user.get(uid=message["user_uid"])
|
||||
message["user"] = user and user["username"] or None
|
||||
return (message["user"] or "") + ": " + (message["text"] or "")
|
||||
|
||||
async def run():
|
||||
|
||||
result = []
|
||||
for channel in app.db["channel"].find(
|
||||
is_private=False, is_listed=True, tag="public"
|
||||
):
|
||||
print(f"Dumping channel: {channel['label']}.")
|
||||
result += [
|
||||
await fix_message(record)
|
||||
for record in app.db["channel_message"].find(
|
||||
channel_uid=channel["uid"], order_by="created_at"
|
||||
)
|
||||
]
|
||||
print("Dump succesfull!")
|
||||
print("Converting to json.")
|
||||
print("Converting succesful, now writing to dump.txt")
|
||||
with open("dump.txt", "w") as f:
|
||||
f.write("\n\n".join(result))
|
||||
print("Dump written to dump.json")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@cli.command()
|
||||
def statistics():
|
||||
async def run():
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
app.services.statistics.database()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@cli.command()
|
||||
def maintenance():
|
||||
async def run():
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
await app.services.container.maintenance()
|
||||
await app.services.channel_message.maintenance()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
if pathlib.Path(db_path).exists():
|
||||
return
|
||||
print(f"Initializing database at {db_path}")
|
||||
@ -96,50 +33,25 @@ 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",
|
||||
)
|
||||
@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)
|
||||
|
||||
#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('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
||||
def shell(db_path):
|
||||
Shell(db_path).run()
|
||||
|
||||
app = Application(db_path=f"sqlite:///{db_path}")
|
||||
start_ipython(argv=[], user_ns={'app': app})
|
||||
|
||||
def main():
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
|
||||
except ImportError:
|
||||
print("Could not import sentry_sdk")
|
||||
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
261
src/snek/app.py
@ -1,20 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from snek import snode
|
||||
from snek.system.ads import AsyncDataSet
|
||||
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 concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp_session import (
|
||||
get_session as session_get,
|
||||
@ -25,70 +21,46 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
from app.app import Application as BaseApplication
|
||||
from jinja2 import FileSystemLoader
|
||||
|
||||
from snek.forum import setup_forum
|
||||
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, csp_middleware
|
||||
from snek.system.middleware import auth_middleware, cors_middleware
|
||||
from snek.system.profiler import profiler_handler
|
||||
from snek.system.stats import (
|
||||
create_stats_structure,
|
||||
middleware as stats_middleware,
|
||||
stats_handler,
|
||||
)
|
||||
from snek.system.template import (
|
||||
EmojiExtension,
|
||||
LinkifyExtension,
|
||||
PythonExtension,
|
||||
sanitize_html,
|
||||
)
|
||||
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
|
||||
from snek.view.about import AboutHTMLView, AboutMDView
|
||||
from snek.view.avatar import AvatarView
|
||||
from snek.view.channel import (
|
||||
ChannelAttachmentUploadView,
|
||||
ChannelAttachmentView,
|
||||
ChannelDriveApiView,
|
||||
ChannelView,
|
||||
)
|
||||
from snek.view.container import ContainerView
|
||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||
from snek.view.drive import DriveApiView, DriveView
|
||||
from snek.view.drive import DriveView
|
||||
from snek.view.drive import DriveApiView
|
||||
from snek.view.index import IndexView
|
||||
from snek.view.login import LoginView
|
||||
from snek.view.logout import LogoutView
|
||||
from snek.view.push import PushView
|
||||
from snek.view.register import RegisterView
|
||||
from snek.view.repository import RepositoryView
|
||||
from snek.view.rpc import RPCView
|
||||
from snek.view.repository import RepositoryView
|
||||
from snek.view.search_user import SearchUserView
|
||||
from snek.view.settings.containers import (
|
||||
ContainersCreateView,
|
||||
ContainersDeleteView,
|
||||
ContainersIndexView,
|
||||
ContainersUpdateView,
|
||||
)
|
||||
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.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
|
||||
@ -98,33 +70,6 @@ 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"] = location.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("/"):
|
||||
@ -134,23 +79,17 @@ async def trailing_slash_middleware(request, handler):
|
||||
|
||||
|
||||
class Application(BaseApplication):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
middlewares = [
|
||||
stats_middleware,
|
||||
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
|
||||
)
|
||||
self.db = AsyncDataSet(kwargs["db_path"].replace("sqlite:///", ""))
|
||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||
self.tasks = asyncio.Queue()
|
||||
self._middlewares.append(session_middleware)
|
||||
@ -159,12 +98,10 @@ 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.forum = None
|
||||
self.setup_router()
|
||||
self.ssh_server = None
|
||||
self.sync_service = None
|
||||
self.executor = None
|
||||
@ -173,31 +110,21 @@ class Application(BaseApplication):
|
||||
self.mappers = get_mappers(app=self)
|
||||
self.broadcast_service = None
|
||||
self.user_availability_service_task = None
|
||||
|
||||
self.setup_router()
|
||||
base_path = pathlib.Path(__file__).parent
|
||||
self.ip2location = IP2Location.IP2Location(
|
||||
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||
)
|
||||
self.on_startup.append(self.prepare_stats)
|
||||
|
||||
self.on_startup.append(self.prepare_asyncio)
|
||||
self.on_startup.append(self.start_user_availability_service)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
# self.on_startup.append(self.prepare_database)
|
||||
|
||||
async def prepare_stats(self, app):
|
||||
app["stats"] = create_stats_structure()
|
||||
print("Stats prepared", flush=True)
|
||||
|
||||
self.on_startup.append(self.prepare_database)
|
||||
|
||||
@property
|
||||
def uptime_seconds(self):
|
||||
return (datetime.now() - self.time_start).total_seconds()
|
||||
|
||||
@property
|
||||
@property
|
||||
def uptime(self):
|
||||
return self._format_uptime(self.uptime_seconds)
|
||||
|
||||
def _format_uptime(self, seconds):
|
||||
|
||||
def _format_uptime(self,seconds):
|
||||
seconds = int(seconds)
|
||||
days, seconds = divmod(seconds, 86400)
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
@ -215,16 +142,14 @@ 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()
|
||||
)
|
||||
|
||||
async def start_user_availability_service(self, app):
|
||||
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())
|
||||
|
||||
@ -232,10 +157,6 @@ class Application(BaseApplication):
|
||||
# app.loop = asyncio.get_running_loop()
|
||||
app.executor = ThreadPoolExecutor(max_workers=200)
|
||||
app.loop.set_default_executor(self.executor)
|
||||
# for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
# app.loop.add_signal_handler(
|
||||
# sig, lambda: asyncio.create_task(self.services.container.shutdown())
|
||||
# )
|
||||
|
||||
async def create_task(self, task):
|
||||
await self.tasks.put(task)
|
||||
@ -252,10 +173,20 @@ class Application(BaseApplication):
|
||||
self.db.commit()
|
||||
|
||||
async def prepare_database(self, app):
|
||||
await self.db.query_raw("PRAGMA journal_mode=WAL")
|
||||
await self.db.query_raw("PRAGMA syncnorm=off")
|
||||
self.db.query("PRAGMA journal_mode=WAL")
|
||||
self.db.query("PRAGMA syncnorm=off")
|
||||
|
||||
await self.services.drive.prepare_all()
|
||||
try:
|
||||
if not self.db["user"].has_index("username"):
|
||||
self.db["user"].create_index("username", unique=True)
|
||||
if not self.db["channel_member"].has_index(["channel_uid", "user_uid"]):
|
||||
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
|
||||
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
|
||||
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
|
||||
except:
|
||||
pass
|
||||
|
||||
await app.services.drive.prepare_all()
|
||||
self.loop.create_task(self.task_runner())
|
||||
|
||||
def setup_router(self):
|
||||
@ -267,7 +198,6 @@ class Application(BaseApplication):
|
||||
show_index=True,
|
||||
)
|
||||
self.router.add_view("/profiler.html", profiler_handler)
|
||||
self.router.add_view("/container/sock/{channel_uid}.json", ContainerView)
|
||||
self.router.add_view("/about.html", AboutHTMLView)
|
||||
self.router.add_view("/about.md", AboutMDView)
|
||||
self.router.add_view("/logout.json", LogoutView)
|
||||
@ -278,7 +208,6 @@ 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)
|
||||
@ -294,16 +223,8 @@ 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/{channel_uid}/drive.json", ChannelDriveApiView)
|
||||
self.router.add_view(
|
||||
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
|
||||
)
|
||||
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)
|
||||
@ -311,45 +232,29 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/drive.json", DriveApiView)
|
||||
self.router.add_view("/drive.html", DriveView)
|
||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
self.router.add_get("/stats.html", stats_handler)
|
||||
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)
|
||||
setup_forum(self)
|
||||
# self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
self.add_subapp("/git",self.git)
|
||||
|
||||
#self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
async def handle_test(self, request):
|
||||
|
||||
return await whitelist_attributes(
|
||||
self.render_template("test.html", request, context={"name": "retoor"})
|
||||
return await self.render_template(
|
||||
"test.html", request, context={"name": "retoor"}
|
||||
)
|
||||
|
||||
async def handle_http_get(self, request: web.Request):
|
||||
@ -374,8 +279,9 @@ class Application(BaseApplication):
|
||||
async for subscribed_channel in self.services.channel_member.find(
|
||||
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
||||
):
|
||||
|
||||
parent_object = await subscribed_channel.get_channel()
|
||||
|
||||
|
||||
item = {}
|
||||
other_user = await self.services.channel_member.get_other_dm_user(
|
||||
subscribed_channel["channel_uid"], request.session.get("uid")
|
||||
@ -417,21 +323,15 @@ class Application(BaseApplication):
|
||||
request.session.get("uid")
|
||||
)
|
||||
|
||||
try:
|
||||
context["nonce"] = request["csp_nonce"]
|
||||
except:
|
||||
context["nonce"] = "?"
|
||||
|
||||
rendered = await super().render_template(template, request, context)
|
||||
|
||||
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 = []
|
||||
|
||||
@ -440,12 +340,12 @@ class Application(BaseApplication):
|
||||
user_static_path = await self.services.user.get_static_path(uid)
|
||||
if user_static_path:
|
||||
paths.append(user_static_path)
|
||||
|
||||
|
||||
for admin_uid in self.services.user.get_admin_uids():
|
||||
user_static_path = await self.services.user.get_static_path(admin_uid)
|
||||
if user_static_path:
|
||||
paths.append(user_static_path)
|
||||
|
||||
|
||||
paths.append(self.static_path)
|
||||
|
||||
for path in paths:
|
||||
@ -455,7 +355,7 @@ class Application(BaseApplication):
|
||||
|
||||
async def get_user_template_loader(self, uid=None):
|
||||
template_paths = []
|
||||
for admin_uid in await self.services.user.get_admin_uids():
|
||||
for admin_uid in self.services.user.get_admin_uids():
|
||||
user_template_path = await self.services.user.get_template_path(admin_uid)
|
||||
if user_template_path:
|
||||
template_paths.append(user_template_path)
|
||||
@ -465,39 +365,16 @@ class Application(BaseApplication):
|
||||
if user_template_path:
|
||||
template_paths.append(user_template_path)
|
||||
|
||||
|
||||
template_paths.append(self.template_path)
|
||||
return FileSystemLoader(template_paths)
|
||||
|
||||
@asynccontextmanager
|
||||
async def no_save(self):
|
||||
stats = {"count": 0}
|
||||
|
||||
async def patched_save(*args, **kwargs):
|
||||
await self.cache.set(args[0]["uid"], args[0])
|
||||
stats["count"] = stats["count"] + 1
|
||||
print(f"save is ignored {stats['count']} times")
|
||||
return args[0]
|
||||
|
||||
save_original = self.services.channel_message.mapper.save
|
||||
self.services.channel_message.mapper.save = patched_save
|
||||
raised_exception = None
|
||||
try:
|
||||
yield
|
||||
except Exception as ex:
|
||||
raised_exception = ex
|
||||
finally:
|
||||
self.services.channel_message.mapper.save = save_original
|
||||
if raised_exception:
|
||||
raise raised_exception
|
||||
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
|
||||
|
||||
async def main():
|
||||
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)
|
||||
await web._run_app(app, port=8081, host="0.0.0.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
|
||||
class LoadBalancer:
|
||||
def __init__(self, backend_ports):
|
||||
self.backend_ports = backend_ports
|
||||
@ -9,29 +8,27 @@ 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)
|
||||
|
||||
@ -65,10 +62,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)
|
||||
asyncio.create_task(self.monitor())
|
||||
monitor_task = asyncio.create_task(self.monitor())
|
||||
|
||||
# Handle shutdown gracefully
|
||||
try:
|
||||
@ -83,7 +80,6 @@ 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:
|
||||
@ -98,11 +94,10 @@ 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
|
||||
@ -110,20 +105,19 @@ 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":
|
||||
|
||||
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':
|
||||
from snek.sync import app
|
||||
web.run_app(snek, port=port, host='127.0.0.1')
|
||||
else:
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down...")
|
||||
|
||||
|
@ -1,114 +0,0 @@
|
||||
# forum_app.py
|
||||
import aiohttp.web
|
||||
|
||||
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
|
||||
|
||||
|
||||
class ForumApplication(aiohttp.web.Application):
|
||||
def __init__(self, parent, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.parent = parent
|
||||
self.render_template = self.parent.render_template
|
||||
# Set up routes
|
||||
self.setup_routes()
|
||||
|
||||
# Set up notification listeners
|
||||
self.setup_notifications()
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.parent.db
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
return self.parent.services
|
||||
|
||||
def setup_routes(self):
|
||||
"""Set up all forum routes"""
|
||||
# API routes
|
||||
self.router.add_view("/index.html", ForumIndexView)
|
||||
self.router.add_route("GET", "/api/forums", ForumView.get_forums)
|
||||
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
|
||||
self.router.add_route(
|
||||
"POST", "/api/forums/{slug}/threads", ForumView.create_thread
|
||||
)
|
||||
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
|
||||
self.router.add_route(
|
||||
"POST", "/api/threads/{thread_uid}/posts", ForumView.create_post
|
||||
)
|
||||
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
|
||||
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
|
||||
self.router.add_route(
|
||||
"POST", "/api/posts/{post_uid}/like", ForumView.toggle_like
|
||||
)
|
||||
self.router.add_route(
|
||||
"POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin
|
||||
)
|
||||
self.router.add_route(
|
||||
"POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock
|
||||
)
|
||||
|
||||
# WebSocket route
|
||||
self.router.add_view("/ws", ForumWebSocketView)
|
||||
|
||||
# Static HTML route
|
||||
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
|
||||
|
||||
def setup_notifications(self):
|
||||
"""Set up notification listeners for WebSocket broadcasting"""
|
||||
# Forum notifications
|
||||
self.services.forum.add_notification_listener(
|
||||
"forum_created", self.on_forum_event
|
||||
)
|
||||
|
||||
# Thread notifications
|
||||
self.services.thread.add_notification_listener(
|
||||
"thread_created", self.on_thread_event
|
||||
)
|
||||
|
||||
# Post notifications
|
||||
self.services.post.add_notification_listener("post_created", self.on_post_event)
|
||||
self.services.post.add_notification_listener("post_edited", self.on_post_event)
|
||||
self.services.post.add_notification_listener("post_deleted", self.on_post_event)
|
||||
|
||||
# Like notifications
|
||||
self.services.post_like.add_notification_listener(
|
||||
"post_liked", self.on_like_event
|
||||
)
|
||||
self.services.post_like.add_notification_listener(
|
||||
"post_unliked", self.on_like_event
|
||||
)
|
||||
|
||||
async def on_forum_event(self, event_type, data):
|
||||
"""Handle forum events"""
|
||||
await ForumWebSocketView.broadcast_update(self, event_type, data)
|
||||
|
||||
async def on_thread_event(self, event_type, data):
|
||||
"""Handle thread events"""
|
||||
await ForumWebSocketView.broadcast_update(self, event_type, data)
|
||||
|
||||
async def on_post_event(self, event_type, data):
|
||||
"""Handle post events"""
|
||||
await ForumWebSocketView.broadcast_update(self, event_type, data)
|
||||
|
||||
async def on_like_event(self, event_type, data):
|
||||
"""Handle like events"""
|
||||
await ForumWebSocketView.broadcast_update(self, event_type, data)
|
||||
|
||||
async def serve_forum_html(self, request):
|
||||
"""Serve the forum HTML with the web component"""
|
||||
return await self.parent.render_template("forum.html", request)
|
||||
|
||||
# return aiohttp.web.Response(text=html, content_type="text/html")
|
||||
|
||||
|
||||
# Integration with main app
|
||||
def setup_forum(app):
|
||||
"""Set up forum sub-application"""
|
||||
forum_app = ForumApplication(app)
|
||||
app.add_subapp("/forum", forum_app)
|
||||
app.forum_app = forum_app
|
||||
# Register models and services if needed
|
||||
# This would typically be done in your main app initialization
|
||||
|
||||
return forum_app
|
@ -1,22 +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.forum import ForumMapper, PostLikeMapper, PostMapper, ThreadMapper
|
||||
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(
|
||||
**{
|
||||
@ -31,11 +30,6 @@ def get_mappers(app=None):
|
||||
"repository": RepositoryMapper(app=app),
|
||||
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||
"container": ContainerMapper(app=app),
|
||||
"push": PushMapper(app=app),
|
||||
"forum": ForumMapper(app=app),
|
||||
"thread": ThreadMapper(app=app),
|
||||
"post": PostMapper(app=app),
|
||||
"post_like": PostLikeMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
from snek.model.container import Container
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ContainerMapper(BaseMapper):
|
||||
model_class = Container
|
||||
table_name = "container"
|
||||
table_name = "container"
|
@ -1,23 +0,0 @@
|
||||
# mapper/forum.py
|
||||
from snek.model.forum import ForumModel, PostLikeModel, PostModel, ThreadModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ForumMapper(BaseMapper):
|
||||
table_name = "forum"
|
||||
model_class = ForumModel
|
||||
|
||||
|
||||
class ThreadMapper(BaseMapper):
|
||||
table_name = "thread"
|
||||
model_class = ThreadModel
|
||||
|
||||
|
||||
class PostMapper(BaseMapper):
|
||||
table_name = "post"
|
||||
model_class = PostModel
|
||||
|
||||
|
||||
class PostLikeMapper(BaseMapper):
|
||||
table_name = "post_like"
|
||||
model_class = PostLikeModel
|
@ -1,7 +0,0 @@
|
||||
from snek.model.push_registration import PushRegistrationModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class PushMapper(BaseMapper):
|
||||
model_class = PushRegistrationModel
|
||||
table_name = "push_registration"
|
@ -6,11 +6,11 @@ class UserMapper(BaseMapper):
|
||||
table_name = "user"
|
||||
model_class = UserModel
|
||||
|
||||
async def get_admin_uids(self):
|
||||
def get_admin_uids(self):
|
||||
try:
|
||||
return [
|
||||
user["uid"]
|
||||
for user in await self.db.query(
|
||||
for user in self.db.query(
|
||||
"SELECT uid FROM user WHERE is_admin = :is_admin",
|
||||
{"is_admin": True},
|
||||
)
|
||||
|
@ -1,20 +1,18 @@
|
||||
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.forum import ForumModel, PostLikeModel, PostModel, ThreadModel
|
||||
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
|
||||
|
||||
|
||||
@ -33,11 +31,6 @@ def get_models():
|
||||
"repository": RepositoryModel,
|
||||
"channel_attachment": ChannelAttachmentModel,
|
||||
"container": Container,
|
||||
"push_registration": PushRegistrationModel,
|
||||
"forum": ForumModel,
|
||||
"thread": ThreadModel,
|
||||
"post": PostModel,
|
||||
"post_like": PostLikeModel,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -11,21 +11,11 @@ class ChannelModel(BaseModel):
|
||||
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
|
||||
index = ModelField(name="index", required=True, kind=int, value=1000)
|
||||
last_message_on = ModelField(name="last_message_on", required=False, kind=str)
|
||||
history_start = ModelField(name="history_start", required=False, kind=str)
|
||||
|
||||
@property
|
||||
def is_dm(self):
|
||||
return "dm" in self["tag"].lower()
|
||||
|
||||
async def get_last_message(self) -> ChannelMessageModel:
|
||||
history_start_filter = ""
|
||||
if self["history_start"]:
|
||||
history_start_filter = f" AND created_at > '{self['history_start']}' "
|
||||
try:
|
||||
async for model in self.app.services.channel_message.query(
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid"
|
||||
+ history_start_filter
|
||||
+ " ORDER BY created_at DESC LIMIT 1",
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1",
|
||||
{"channel_uid": self["uid"]},
|
||||
):
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
from snek.system.model import BaseModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
@ -10,6 +11,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")
|
||||
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
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)
|
||||
@ -12,11 +10,7 @@ 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,6 +1,5 @@
|
||||
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)
|
||||
|
@ -9,9 +9,7 @@ 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):
|
||||
|
@ -1,114 +0,0 @@
|
||||
# models/forum.py
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ForumModel(BaseModel):
|
||||
"""Forum categories"""
|
||||
|
||||
name = ModelField(
|
||||
name="name", required=True, kind=str, min_length=3, max_length=100
|
||||
)
|
||||
description = ModelField(
|
||||
name="description", required=False, kind=str, max_length=500
|
||||
)
|
||||
slug = ModelField(
|
||||
name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True
|
||||
)
|
||||
icon = ModelField(name="icon", required=False, kind=str)
|
||||
position = ModelField(name="position", required=True, kind=int, value=0)
|
||||
is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
|
||||
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
|
||||
thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
|
||||
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
|
||||
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
|
||||
last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
|
||||
|
||||
async def get_threads(self, limit=50, offset=0):
|
||||
async for thread in self.app.services.thread.find(
|
||||
forum_uid=self["uid"],
|
||||
deleted_at=None,
|
||||
_limit=limit,
|
||||
_offset=offset,
|
||||
order_by="-last_post_at",
|
||||
# order_by="is_pinned DESC, last_post_at DESC"
|
||||
):
|
||||
yield thread
|
||||
|
||||
async def increment_thread_count(self):
|
||||
self["thread_count"] += 1
|
||||
await self.save()
|
||||
|
||||
async def increment_post_count(self):
|
||||
self["post_count"] += 1
|
||||
await self.save()
|
||||
|
||||
|
||||
# models/thread.py
|
||||
class ThreadModel(BaseModel):
|
||||
"""Forum threads"""
|
||||
|
||||
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
|
||||
title = ModelField(
|
||||
name="title", required=True, kind=str, min_length=5, max_length=200
|
||||
)
|
||||
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
|
||||
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
|
||||
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
|
||||
is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
|
||||
view_count = ModelField(name="view_count", required=True, kind=int, value=0)
|
||||
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
|
||||
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
|
||||
last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
|
||||
|
||||
async def get_posts(self, limit=50, offset=0):
|
||||
async for post in self.app.services.post.find(
|
||||
thread_uid=self["uid"],
|
||||
deleted_at=None,
|
||||
_limit=limit,
|
||||
_offset=offset,
|
||||
order_by="created_at",
|
||||
):
|
||||
yield post
|
||||
|
||||
async def increment_view_count(self):
|
||||
self["view_count"] += 1
|
||||
await self.save()
|
||||
|
||||
async def increment_post_count(self):
|
||||
self["post_count"] += 1
|
||||
self["last_post_at"] = self.app.services.get_timestamp()
|
||||
await self.save()
|
||||
|
||||
|
||||
# models/post.py
|
||||
class PostModel(BaseModel):
|
||||
"""Forum posts"""
|
||||
|
||||
thread_uid = ModelField(name="thread_uid", required=True, kind=str)
|
||||
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
|
||||
content = ModelField(
|
||||
name="content", required=True, kind=str, min_length=1, max_length=10000
|
||||
)
|
||||
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
|
||||
edited_at = ModelField(name="edited_at", required=False, kind=str)
|
||||
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
|
||||
is_first_post = ModelField(
|
||||
name="is_first_post", required=True, kind=bool, value=False
|
||||
)
|
||||
like_count = ModelField(name="like_count", required=True, kind=int, value=0)
|
||||
|
||||
async def get_author(self):
|
||||
return await self.app.services.user.get(uid=self["created_by_uid"])
|
||||
|
||||
async def is_liked_by(self, user_uid):
|
||||
return await self.app.services.post_like.exists(
|
||||
post_uid=self["uid"], user_uid=user_uid
|
||||
)
|
||||
|
||||
|
||||
# models/post_like.py
|
||||
class PostLikeModel(BaseModel):
|
||||
"""Post likes"""
|
||||
|
||||
post_uid = ModelField(name="post_uid", required=True, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
@ -1,8 +0,0 @@
|
||||
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)
|
@ -1,10 +1,14 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class RepositoryModel(BaseModel):
|
||||
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
|
||||
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
|
||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||
|
||||
|
||||
|
||||
|
@ -31,14 +31,6 @@ 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
|
||||
|
@ -1,54 +1,51 @@
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
import snek.serpentarium
|
||||
|
||||
import time
|
||||
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
durations = []
|
||||
|
||||
|
||||
def task1():
|
||||
global durations
|
||||
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),
|
||||
executor.submit(task1),
|
||||
tasks = [executor.submit(task1),
|
||||
executor.submit(task1),
|
||||
executor.submit(task1),
|
||||
executor.submit(task1)
|
||||
]
|
||||
for task in tasks:
|
||||
task.result()
|
||||
|
||||
|
||||
import dataset
|
||||
|
||||
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)
|
||||
|
@ -1,70 +1,44 @@
|
||||
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.forum import ForumService, PostLikeService, PostService, ThreadService
|
||||
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.statistics import StatisticsService
|
||||
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
|
||||
|
||||
_service_registry = {}
|
||||
|
||||
|
||||
def register_service(name, service_cls):
|
||||
_service_registry[name] = service_cls
|
||||
|
||||
|
||||
register = register_service
|
||||
|
||||
from snek.service.db import DBService
|
||||
|
||||
@functools.cache
|
||||
def get_services(app):
|
||||
result = Object(
|
||||
return Object(
|
||||
**{
|
||||
name: service_cls(app=app)
|
||||
for name, service_cls in _service_registry.items()
|
||||
"user": UserService(app=app),
|
||||
"channel_member": ChannelMemberService(app=app),
|
||||
"channel": ChannelService(app=app),
|
||||
"channel_message": ChannelMessageService(app=app),
|
||||
"chat": ChatService(app=app),
|
||||
"socket": SocketService(app=app),
|
||||
"notification": NotificationService(app=app),
|
||||
"util": UtilService(app=app),
|
||||
"drive": DriveService(app=app),
|
||||
"drive_item": DriveItemService(app=app),
|
||||
"user_property": UserPropertyService(app=app),
|
||||
"repository": RepositoryService(app=app),
|
||||
"db": DBService(app=app),
|
||||
"channel_attachment": ChannelAttachmentService(app=app),
|
||||
"container": ContainerService(app=app),
|
||||
}
|
||||
)
|
||||
result.register = register_service
|
||||
return result
|
||||
|
||||
|
||||
def get_service(name, app=None):
|
||||
return get_services(app=app)[name]
|
||||
|
||||
|
||||
# Registering all services
|
||||
register_service("user", UserService)
|
||||
register_service("channel_member", ChannelMemberService)
|
||||
register_service("channel", ChannelService)
|
||||
register_service("channel_message", ChannelMessageService)
|
||||
register_service("chat", ChatService)
|
||||
register_service("socket", SocketService)
|
||||
register_service("notification", NotificationService)
|
||||
register_service("util", UtilService)
|
||||
register_service("drive", DriveService)
|
||||
register_service("drive_item", DriveItemService)
|
||||
register_service("user_property", UserPropertyService)
|
||||
register_service("repository", RepositoryService)
|
||||
register_service("db", DBService)
|
||||
register_service("channel_attachment", ChannelAttachmentService)
|
||||
register_service("container", ContainerService)
|
||||
register_service("push", PushService)
|
||||
register_service("statistics", StatisticsService)
|
||||
register_service("forum", ForumService)
|
||||
register_service("thread", ThreadService)
|
||||
register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
|
@ -1,26 +1,19 @@
|
||||
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_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):
|
||||
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):
|
||||
@ -63,7 +56,6 @@ 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}.")
|
||||
|
||||
@ -76,10 +68,7 @@ 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):
|
||||
@ -113,11 +102,6 @@ class ChannelService(BaseService):
|
||||
channel = await self.get(uid=channel_member["channel_uid"])
|
||||
yield channel
|
||||
|
||||
async def clear(self, channel_uid):
|
||||
model = await self.get(uid=channel_uid)
|
||||
model["history_from"] = datetime.now()
|
||||
await self.save(model)
|
||||
|
||||
async def ensure_public_channel(self, created_by_uid):
|
||||
model = await self.get(is_listed=True, tag="public")
|
||||
is_moderator = False
|
||||
|
@ -1,25 +1,25 @@
|
||||
import mimetypes
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
import urllib.parse
|
||||
import pathlib
|
||||
import mimetypes
|
||||
import uuid
|
||||
|
||||
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}.")
|
||||
|
||||
|
@ -1,60 +1,16 @@
|
||||
import time
|
||||
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.template import sanitize_html
|
||||
|
||||
|
||||
class ChannelMessageService(BaseService):
|
||||
mapper_name = "channel_message"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._configured_indexes = False
|
||||
|
||||
async def maintenance(self):
|
||||
for message in self.mapper.db["channel_message"].find():
|
||||
print(message)
|
||||
try:
|
||||
message = await self.get(uid=message["uid"])
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"uid": message["uid"],
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
if html != message["html"]:
|
||||
print("Reredefined message", message["uid"])
|
||||
|
||||
except Exception as ex:
|
||||
time.sleep(0.1)
|
||||
print(ex, flush=True)
|
||||
|
||||
while True:
|
||||
changed = 0
|
||||
async for message in self.find(is_final=False):
|
||||
message["is_final"] = True
|
||||
await self.save(message)
|
||||
changed += 1
|
||||
async for message in self.find(is_final=None):
|
||||
message["is_final"] = False
|
||||
await self.save(message)
|
||||
changed += 1
|
||||
if not changed:
|
||||
break
|
||||
|
||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||
model = await self.new()
|
||||
|
||||
model["channel_uid"] = channel_uid
|
||||
model["user_uid"] = user_uid
|
||||
model["message"] = message
|
||||
model["is_final"] = is_final
|
||||
model['is_final'] = is_final
|
||||
|
||||
context = {}
|
||||
|
||||
@ -72,25 +28,10 @@ class ChannelMessageService(BaseService):
|
||||
try:
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
model["html"] = sanitize_html(model["html"])
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
|
||||
if await super().save(model):
|
||||
if not self._configured_indexes:
|
||||
if not self.mapper.db["channel_message"].has_index(
|
||||
["is_final", "user_uid", "channel_uid"]
|
||||
):
|
||||
self.mapper.db["channel_message"].create_index(
|
||||
["is_final", "user_uid", "channel_uid"], unique=False
|
||||
)
|
||||
if not self.mapper.db["channel_message"].has_index(["uid"]):
|
||||
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
|
||||
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
|
||||
self.mapper.db["channel_message"].create_index(
|
||||
["deleted_at"], unique=False
|
||||
)
|
||||
self._configured_indexes = True
|
||||
return model
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
@ -98,11 +39,6 @@ class ChannelMessageService(BaseService):
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
# if not message["html"].startswith("<chat-message"):
|
||||
# message = await self.get(uid=message["uid"])
|
||||
# await self.save(message)
|
||||
|
||||
return {
|
||||
"uid": message["uid"],
|
||||
"color": user["color"],
|
||||
@ -118,7 +54,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"],
|
||||
@ -129,22 +65,15 @@ class ChannelMessageService(BaseService):
|
||||
)
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
model["html"] = sanitize_html(model["html"])
|
||||
return await super().save(model)
|
||||
|
||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return []
|
||||
history_start_filter = ""
|
||||
if channel["history_start"]:
|
||||
history_start_filter = f" AND created_at > '{channel['history_start']}'"
|
||||
results = []
|
||||
offset = page * page_size
|
||||
try:
|
||||
if timestamp:
|
||||
async for model in self.query(
|
||||
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
@ -155,27 +84,27 @@ class ChannelMessageService(BaseService):
|
||||
results.append(model)
|
||||
elif page > 0:
|
||||
async for model in self.query(
|
||||
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size",
|
||||
*{
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
"offset": offset,
|
||||
"timestamp": timestamp,
|
||||
}.values(),
|
||||
},
|
||||
):
|
||||
results.append(model)
|
||||
else:
|
||||
async for model in self.query(
|
||||
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
*{
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
"offset": offset,
|
||||
}.values(),
|
||||
},
|
||||
):
|
||||
results.append(model)
|
||||
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
except:
|
||||
pass
|
||||
results.sort(key=lambda x: x["created_at"])
|
||||
return results
|
||||
|
@ -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,29 +28,15 @@ 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.get(
|
||||
channel_uid=channel_uid, user_uid=user_uid, is_final=False
|
||||
channel_message = await self.services.channel_message.create(
|
||||
channel_uid, user_uid, message,is_final
|
||||
)
|
||||
if channel_message:
|
||||
channel_message["message"] = message
|
||||
channel_message["is_final"] = is_final
|
||||
if not channel_message["is_final"]:
|
||||
async with self.app.no_save():
|
||||
await self.services.channel_message.save(channel_message)
|
||||
else:
|
||||
await self.services.channel_message.save(channel_message)
|
||||
else:
|
||||
channel_message = await self.services.channel_message.create(
|
||||
channel_uid, user_uid, message, is_final
|
||||
)
|
||||
channel_message_uid = channel_message["uid"]
|
||||
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
|
@ -1,114 +1,9 @@
|
||||
from snek.system.docker import ComposeFileManager
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ContainerService(BaseService):
|
||||
mapper_name = "container"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.compose_path = "snek-container-compose.yml"
|
||||
self.compose = ComposeFileManager(
|
||||
self.compose_path, self.container_event_handler
|
||||
)
|
||||
self.event_listeners = {}
|
||||
|
||||
async def shutdown(self):
|
||||
return await self.compose.shutdown()
|
||||
|
||||
async def add_event_listener(self, name, event, event_handler):
|
||||
if name not in self.event_listeners:
|
||||
self.event_listeners[name] = {}
|
||||
if event not in self.event_listeners[name]:
|
||||
self.event_listeners[name][event] = []
|
||||
self.event_listeners[name][event].append(event_handler)
|
||||
|
||||
async def remove_event_listener(self, name, event, event_handler):
|
||||
if name in self.event_listeners and event in self.event_listeners[name]:
|
||||
try:
|
||||
self.event_listeners[name][event].remove(event_handler)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def container_event_handler(self, name, event, data):
|
||||
event_listeners = self.event_listeners.get(name, {})
|
||||
handlers = event_listeners.get(event, [])
|
||||
for handler in handlers:
|
||||
if not await handler(data):
|
||||
handlers.remove(handler)
|
||||
|
||||
async def get_instances(self):
|
||||
return list(self.compose.list_instances())
|
||||
|
||||
async def get_container_name(self, channel_uid):
|
||||
if channel_uid.startswith("channel-"):
|
||||
return channel_uid
|
||||
return f"channel-{channel_uid}"
|
||||
|
||||
async def get(self, channel_uid):
|
||||
return await self.compose.get_instance(
|
||||
await self.get_container_name(channel_uid)
|
||||
)
|
||||
|
||||
async def stop(self, channel_uid):
|
||||
return await self.compose.stop(await self.get_container_name(channel_uid))
|
||||
|
||||
async def start(self, channel_uid):
|
||||
return await self.compose.start(await self.get_container_name(channel_uid))
|
||||
|
||||
async def maintenance(self):
|
||||
async for channel in self.services.channel.find():
|
||||
if not await self.get(channel["uid"]):
|
||||
print("Creating container for channel", channel["uid"])
|
||||
result = await self.create(channel_uid=channel["uid"])
|
||||
print(result)
|
||||
|
||||
async def get_status(self, channel_uid):
|
||||
return await self.compose.get_instance_status(
|
||||
await self.get_container_name(channel_uid)
|
||||
)
|
||||
|
||||
async def write_stdin(self, channel_uid, data):
|
||||
return await self.compose.write_stdin(
|
||||
await self.get_container_name(channel_uid), data
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
test = await self.compose.get_instance(name)
|
||||
if test:
|
||||
return test
|
||||
|
||||
self.compose.create_instance(
|
||||
name,
|
||||
image,
|
||||
command,
|
||||
cpus,
|
||||
memory,
|
||||
ports,
|
||||
[
|
||||
"./"
|
||||
+ str(await self.services.channel.get_home_folder(channel_uid))
|
||||
+ ":"
|
||||
+ "/root"
|
||||
],
|
||||
)
|
||||
return await self.compose.get_instance(name)
|
||||
|
||||
async def create2(
|
||||
self, id, name, status, resources=None, user_uid=None, path=None, readonly=False
|
||||
):
|
||||
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False):
|
||||
model = await self.new()
|
||||
model["id"] = id
|
||||
model["name"] = name
|
||||
@ -123,3 +18,12 @@ class ContainerService(BaseService):
|
||||
if await super().save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create container: {model.errors}")
|
||||
|
||||
async def get(self, id):
|
||||
return await self.mapper.get(id)
|
||||
|
||||
async def update(self, model):
|
||||
return await self.mapper.update(model)
|
||||
|
||||
async def delete(self, id):
|
||||
return await self.mapper.delete(id)
|
||||
|
@ -1,21 +1,23 @@
|
||||
import dataset
|
||||
|
||||
from snek.system.service import BaseService
|
||||
import dataset
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
class DBService(BaseService):
|
||||
|
||||
|
||||
async def get_db(self, user_uid):
|
||||
|
||||
|
||||
home_folder = await self.app.services.user.get_home_folder(user_uid)
|
||||
home_folder.mkdir(parents=True, exist_ok=True)
|
||||
db_path = home_folder.joinpath("snek/user.db")
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return dataset.connect("sqlite:///" + str(db_path))
|
||||
|
||||
return dataset.connect("sqlite:///" + str(db_path))
|
||||
|
||||
async def insert(self, user_uid, table_name, values):
|
||||
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)
|
||||
@ -31,7 +33,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):
|
||||
@ -43,13 +45,14 @@ 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 {})]
|
||||
|
||||
@ -59,6 +62,8 @@ 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:
|
||||
|
@ -1,309 +0,0 @@
|
||||
# services/forum.py
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
||||
|
||||
|
||||
class BaseForumService(BaseService):
|
||||
"""
|
||||
Base mix-in that gives a service `add_notification_listener`,
|
||||
an internal `_dispatch_event` helper, and a public `notify` method.
|
||||
"""
|
||||
|
||||
def get_timestamp(self):
|
||||
return now()
|
||||
|
||||
def generate_uid(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
# Map event name -> list of listener callables
|
||||
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def add_notification_listener(
|
||||
self, event_name: str, listener: EventListener
|
||||
) -> None:
|
||||
"""
|
||||
Register a callback to be fired when `event_name` happens.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_name : str
|
||||
The name of the domain event, e.g. "post_created".
|
||||
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
||||
Your handler; can be async or sync.
|
||||
"""
|
||||
if not callable(listener):
|
||||
raise TypeError("listener must be callable")
|
||||
|
||||
self._listeners[event_name].append(listener)
|
||||
|
||||
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
||||
"""Invoke every listener for the given event."""
|
||||
for listener in self._listeners.get(event_name, []):
|
||||
if hasattr(listener, "__await__"): # async function or coro
|
||||
await listener(event_name, data)
|
||||
else: # plain sync function
|
||||
listener(event_name, data)
|
||||
|
||||
async def notify(self, event_name: str, data: Any) -> None:
|
||||
"""
|
||||
Public method to trigger notification to all listeners of an event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_name : str
|
||||
The name of the event to notify listeners about.
|
||||
data : Any
|
||||
The data to pass to the listeners.
|
||||
"""
|
||||
await self._dispatch_event(event_name, data)
|
||||
|
||||
|
||||
class ForumService(BaseForumService):
|
||||
mapper_name = "forum"
|
||||
|
||||
async def create_forum(
|
||||
self, name, description, created_by_uid, slug=None, icon=None
|
||||
):
|
||||
if not slug:
|
||||
slug = self.generate_slug(name)
|
||||
|
||||
# Check if slug exists
|
||||
existing = await self.get(slug=slug)
|
||||
if existing:
|
||||
slug = f"{slug}-{self.generate_uid()[:8]}"
|
||||
|
||||
model = await self.new()
|
||||
model["name"] = name
|
||||
model["description"] = description
|
||||
model["slug"] = slug
|
||||
model["created_by_uid"] = created_by_uid
|
||||
if icon:
|
||||
model["icon"] = icon
|
||||
|
||||
if await self.save(model):
|
||||
await self.notify("forum_created", model)
|
||||
return model
|
||||
raise Exception(f"Failed to create forum: {model.errors}")
|
||||
|
||||
def generate_slug(self, text):
|
||||
# Convert to lowercase and replace spaces with hyphens
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||
slug = re.sub(r"[-\s]+", "-", slug)
|
||||
return slug
|
||||
|
||||
async def get_active_forums(self):
|
||||
async for forum in self.find(is_active=True, order_by="position"):
|
||||
yield forum
|
||||
|
||||
async def update_last_post(self, forum_uid, thread_uid):
|
||||
forum = await self.get(uid=forum_uid)
|
||||
if forum:
|
||||
forum["last_post_at"] = self.get_timestamp()
|
||||
forum["last_thread_uid"] = thread_uid
|
||||
await self.save(forum)
|
||||
|
||||
|
||||
# services/thread.py
|
||||
class ThreadService(BaseForumService):
|
||||
mapper_name = "thread"
|
||||
|
||||
async def create_thread(self, forum_uid, title, content, created_by_uid):
|
||||
# Generate slug
|
||||
slug = self.services.forum.generate_slug(title)
|
||||
|
||||
# Check if slug exists in this forum
|
||||
existing = await self.get(forum_uid=forum_uid, slug=slug)
|
||||
if existing:
|
||||
slug = f"{slug}-{self.generate_uid()[:8]}"
|
||||
|
||||
# Create thread
|
||||
thread = await self.new()
|
||||
thread["forum_uid"] = forum_uid
|
||||
thread["title"] = title
|
||||
thread["slug"] = slug
|
||||
thread["created_by_uid"] = created_by_uid
|
||||
thread["last_post_at"] = self.get_timestamp()
|
||||
thread["last_post_by_uid"] = created_by_uid
|
||||
|
||||
if await self.save(thread):
|
||||
# Create first post
|
||||
post = await self.services.post.create_post(
|
||||
thread_uid=thread["uid"],
|
||||
forum_uid=forum_uid,
|
||||
content=content,
|
||||
created_by_uid=created_by_uid,
|
||||
is_first_post=True,
|
||||
)
|
||||
|
||||
# Update forum counters
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
await forum.increment_thread_count()
|
||||
await self.services.forum.update_last_post(forum_uid, thread["uid"])
|
||||
|
||||
await self.notify(
|
||||
"thread_created", {"thread": thread, "forum_uid": forum_uid}
|
||||
)
|
||||
|
||||
return thread, post
|
||||
raise Exception(f"Failed to create thread: {thread.errors}")
|
||||
|
||||
async def toggle_pin(self, thread_uid, user_uid):
|
||||
thread = await self.get(uid=thread_uid)
|
||||
if not thread:
|
||||
return None
|
||||
|
||||
# Check if user is admin
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user.get("is_admin"):
|
||||
return None
|
||||
|
||||
thread["is_pinned"] = not thread["is_pinned"]
|
||||
await self.save(thread)
|
||||
return thread
|
||||
|
||||
async def toggle_lock(self, thread_uid, user_uid):
|
||||
thread = await self.get(uid=thread_uid)
|
||||
if not thread:
|
||||
return None
|
||||
|
||||
# Check if user is admin or thread creator
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
|
||||
return None
|
||||
|
||||
thread["is_locked"] = not thread["is_locked"]
|
||||
await self.save(thread)
|
||||
return thread
|
||||
|
||||
|
||||
# services/post.py
|
||||
class PostService(BaseForumService):
|
||||
mapper_name = "post"
|
||||
|
||||
async def create_post(
|
||||
self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False
|
||||
):
|
||||
# Check if thread is locked
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if thread["is_locked"] and not is_first_post:
|
||||
raise Exception("Thread is locked")
|
||||
|
||||
post = await self.new()
|
||||
post["thread_uid"] = thread_uid
|
||||
post["forum_uid"] = forum_uid
|
||||
post["content"] = content
|
||||
post["created_by_uid"] = created_by_uid
|
||||
post["is_first_post"] = is_first_post
|
||||
|
||||
if await self.save(post):
|
||||
# Update thread counters
|
||||
if not is_first_post:
|
||||
thread["post_count"] += 1
|
||||
thread["last_post_at"] = self.get_timestamp()
|
||||
thread["last_post_by_uid"] = created_by_uid
|
||||
await self.services.thread.save(thread)
|
||||
|
||||
# Update forum counters
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
await forum.increment_post_count()
|
||||
await self.services.forum.update_last_post(forum_uid, thread_uid)
|
||||
|
||||
await self.notify(
|
||||
"post_created",
|
||||
{"post": post, "thread_uid": thread_uid, "forum_uid": forum_uid},
|
||||
)
|
||||
|
||||
return post
|
||||
raise Exception(f"Failed to create post: {post.errors}")
|
||||
|
||||
async def edit_post(self, post_uid, content, user_uid):
|
||||
post = await self.get(uid=post_uid)
|
||||
if not post:
|
||||
return None
|
||||
|
||||
# Check permissions
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
||||
return None
|
||||
|
||||
post["content"] = content
|
||||
post["edited_at"] = self.get_timestamp()
|
||||
post["edited_by_uid"] = user_uid
|
||||
|
||||
if await self.save(post):
|
||||
await self.notify("post_edited", post)
|
||||
return post
|
||||
return None
|
||||
|
||||
async def delete_post(self, post_uid, user_uid):
|
||||
post = await self.get(uid=post_uid)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
# Check permissions
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
||||
return False
|
||||
|
||||
# Don't allow deleting first post
|
||||
if post["is_first_post"]:
|
||||
return False
|
||||
|
||||
post["deleted_at"] = self.get_timestamp()
|
||||
if await self.save(post):
|
||||
await self.notify("post_deleted", post)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# services/post_like.py
|
||||
class PostLikeService(BaseForumService):
|
||||
mapper_name = "post_like"
|
||||
|
||||
async def toggle_like(self, post_uid, user_uid):
|
||||
# Check if already liked
|
||||
existing = await self.get(post_uid=post_uid, user_uid=user_uid)
|
||||
|
||||
if existing:
|
||||
# Unlike
|
||||
await self.delete(uid=existing["uid"])
|
||||
|
||||
# Update post like count
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
post["like_count"] = max(0, post["like_count"] - 1)
|
||||
await self.services.post.save(post)
|
||||
|
||||
await self.notify(
|
||||
"post_unliked", {"post_uid": post_uid, "user_uid": user_uid}
|
||||
)
|
||||
|
||||
return False
|
||||
else:
|
||||
# Like
|
||||
like = await self.new()
|
||||
like["post_uid"] = post_uid
|
||||
like["user_uid"] = user_uid
|
||||
|
||||
if await self.save(like):
|
||||
# Update post like count
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
post["like_count"] += 1
|
||||
await self.services.post.save(post)
|
||||
|
||||
await self.notify(
|
||||
"post_liked", {"post_uid": post_uid, "user_uid": user_uid}
|
||||
)
|
||||
|
||||
return True
|
||||
return None
|
@ -1,4 +1,3 @@
|
||||
from snek.system.markdown import strip_markdown
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
@ -34,8 +33,6 @@ 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(
|
||||
@ -65,20 +62,4 @@ 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()
|
||||
|
@ -1,267 +0,0 @@
|
||||
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}"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
@ -1,17 +1,13 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
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:
|
||||
@ -19,6 +15,7 @@ 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
|
||||
@ -32,16 +29,18 @@ 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
|
||||
return False
|
||||
|
||||
if not await self.init(user_uid=user_uid, name=name):
|
||||
return False
|
||||
|
@ -1,14 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.service import BaseService
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from snek.system.model import now
|
||||
|
||||
|
||||
class SocketService(BaseService):
|
||||
|
||||
class Socket:
|
||||
@ -17,6 +15,7 @@ class SocketService(BaseService):
|
||||
self.is_connected = True
|
||||
self.user = user
|
||||
|
||||
|
||||
async def send_json(self, data):
|
||||
if not self.is_connected:
|
||||
return False
|
||||
@ -41,6 +40,7 @@ class SocketService(BaseService):
|
||||
self.users = {}
|
||||
self.subscriptions = {}
|
||||
self.last_update = str(datetime.now())
|
||||
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
@ -50,15 +50,14 @@ class SocketService(BaseService):
|
||||
for s in self.sockets:
|
||||
if not s.is_connected:
|
||||
continue
|
||||
if s.user not in users_updated:
|
||||
if not s.user 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)
|
||||
@ -77,11 +76,12 @@ class SocketService(BaseService):
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
count = 0
|
||||
for s in list(self.users.get(user_uid, [])):
|
||||
for s in self.users.get(user_uid, []):
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
await self._broadcast(channel_uid, message)
|
||||
|
||||
@ -102,3 +102,4 @@ class SocketService(BaseService):
|
||||
await s.close()
|
||||
logger.info(f"Removed socket for user {s.user['username']}")
|
||||
self.sockets.remove(s)
|
||||
|
||||
|
@ -1,128 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class StatisticsService(BaseService):
|
||||
|
||||
def database(self):
|
||||
db_path = self.app.db_path.split("///")[-1]
|
||||
print(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Existing analysis code...
|
||||
def get_table_columns(table_name):
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
return cursor.fetchall()
|
||||
|
||||
tables = [
|
||||
"http_access",
|
||||
"user",
|
||||
"channel",
|
||||
"channel_member",
|
||||
"broadcast",
|
||||
"channel_message",
|
||||
"notification",
|
||||
"repository",
|
||||
"test",
|
||||
"drive",
|
||||
"user_property",
|
||||
"a",
|
||||
"channel_attachment",
|
||||
"push_registration",
|
||||
]
|
||||
|
||||
for table in tables:
|
||||
print(f"\n--- Statistics for table: {table} ---")
|
||||
columns = get_table_columns(table)
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
total_rows = cursor.fetchone()[0]
|
||||
print(f"Total rows: {total_rows}")
|
||||
|
||||
for col in columns:
|
||||
cid, name, col_type, notnull, dflt_value, pk = col
|
||||
col_type_upper = col_type.upper()
|
||||
|
||||
cursor.execute(f"SELECT COUNT(DISTINCT '{name}') FROM {table}")
|
||||
distinct_count = cursor.fetchone()[0]
|
||||
print(f"\nColumn: {name} ({col_type})")
|
||||
print(f"Distinct values: {distinct_count}")
|
||||
|
||||
if (
|
||||
"INT" in col_type_upper
|
||||
or "BIGINT" in col_type_upper
|
||||
or "FLOAT" in col_type_upper
|
||||
):
|
||||
cursor.execute(
|
||||
f"SELECT MIN('{name}'), MAX('{name}'), AVG('{name}') FROM {table} WHERE '{name}' IS NOT NULL"
|
||||
)
|
||||
min_val, max_val, avg_val = cursor.fetchone()
|
||||
print(f"Min: {min_val}, Max: {max_val}, Avg: {avg_val}")
|
||||
|
||||
elif "TEXT" in col_type_upper and (
|
||||
"date" in name.lower()
|
||||
or "time" in name.lower()
|
||||
or "created" in name.lower()
|
||||
or "updated" in name.lower()
|
||||
or "on" in name.lower()
|
||||
):
|
||||
cursor.execute(
|
||||
f"SELECT MIN({name}), MAX({name}) FROM {table} WHERE {name} IS NOT NULL"
|
||||
)
|
||||
min_date, max_date = cursor.fetchone()
|
||||
print(f"Earliest: {min_date}, Latest: {max_date}")
|
||||
|
||||
elif "TEXT" in col_type_upper:
|
||||
cursor.execute(
|
||||
f"SELECT LENGTH({name}) FROM {table} WHERE {name} IS NOT NULL"
|
||||
)
|
||||
lengths = [len_row[0] for len_row in cursor.fetchall()]
|
||||
if lengths:
|
||||
avg_length = sum(lengths) / len(lengths)
|
||||
max_length = max(lengths)
|
||||
min_length = min(lengths)
|
||||
print(
|
||||
f"Avg length: {avg_length:.2f}, Max length: {max_length}, Min length: {min_length}"
|
||||
)
|
||||
else:
|
||||
print("No data to compute length statistics.")
|
||||
|
||||
# New statistics functions
|
||||
def get_time_series_stats(table_name, date_column):
|
||||
cursor.execute(
|
||||
f"SELECT strftime('%Y-%m-%d', {date_column}) AS day, COUNT(*) FROM {table_name} GROUP BY day"
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_count_created(table_name, date_column):
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def get_channels_per_user():
|
||||
cursor.execute(
|
||||
"SELECT user_uid, COUNT(*) AS channel_count FROM channel_member GROUP BY user_uid ORDER BY channel_count DESC"
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_online_users():
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM user WHERE last_ping >= datetime('now', '-5 minutes')"
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
# Example usage of new functions
|
||||
messages_per_day = get_time_series_stats("channel_message", "created_at")
|
||||
users_created = get_count_created("user", "created_at")
|
||||
channels_created = get_count_created("channel", "created_at")
|
||||
channels_per_user = get_channels_per_user()
|
||||
online_users = get_online_users()
|
||||
|
||||
# Print or store these stats as needed
|
||||
print("\nMessages per day:", messages_per_day)
|
||||
print("Total users created:", users_created)
|
||||
print("Total channels created:", channels_created)
|
||||
print("Channels per user (top):", channels_per_user[:10])
|
||||
print("Currently online users:", online_users)
|
||||
|
||||
conn.close()
|
@ -12,7 +12,6 @@ class UserService(BaseService):
|
||||
|
||||
async def search(self, query, **kwargs):
|
||||
query = query.strip().lower()
|
||||
kwargs["deleted_at"] = None
|
||||
if not query:
|
||||
return []
|
||||
results = []
|
||||
@ -33,14 +32,14 @@ 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:
|
||||
return False
|
||||
if not security.verify_sync(password, user["password"]):
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
async def authenticate(self, username, password):
|
||||
success = await self.validate_login(username, password)
|
||||
@ -62,12 +61,14 @@ 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():
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def get_by_username_sync(self, username):
|
||||
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
|
||||
return dict(user)
|
||||
@ -101,8 +102,6 @@ class UserService(BaseService):
|
||||
model.username.value = username
|
||||
model.password.value = await security.hash(password)
|
||||
if await self.save(model):
|
||||
for x in range(10):
|
||||
print("Jazeker!!!")
|
||||
if model:
|
||||
channel = await self.services.channel.ensure_public_channel(
|
||||
model["uid"]
|
||||
|
@ -7,8 +7,7 @@ class UserPropertyService(BaseService):
|
||||
mapper_name = "user_property"
|
||||
|
||||
async def set(self, user_uid, name, value):
|
||||
self.mapper.db.upsert(
|
||||
"user_property",
|
||||
self.mapper.db["user_property"].upsert(
|
||||
{
|
||||
"user_uid": user_uid,
|
||||
"name": name,
|
||||
|
422
src/snek/sgit.py
@ -1,51 +1,48 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("git_server")
|
||||
|
||||
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')
|
||||
|
||||
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)
|
||||
@ -53,31 +50,27 @@ 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)
|
||||
@ -87,10 +80,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):
|
||||
@ -105,9 +98,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
|
||||
@ -122,9 +115,9 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def clone_repository(self, request):
|
||||
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
|
||||
@ -133,15 +126,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
|
||||
@ -149,40 +142,34 @@ 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
|
||||
@ -190,15 +177,13 @@ 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)
|
||||
@ -207,46 +192,38 @@ 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):
|
||||
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
|
||||
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(
|
||||
{
|
||||
"id": commit.hexsha,
|
||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||
"date": commit.committed_datetime.isoformat(),
|
||||
"message": commit.message,
|
||||
}
|
||||
)
|
||||
commits.append({
|
||||
"id": commit.hexsha,
|
||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||
"date": commit.committed_datetime.isoformat(),
|
||||
"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)
|
||||
@ -257,58 +234,50 @@ 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):
|
||||
request["username"]
|
||||
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):
|
||||
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
|
||||
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
|
||||
@ -316,168 +285,145 @@ 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):
|
||||
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
|
||||
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(
|
||||
{
|
||||
"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(),
|
||||
}
|
||||
)
|
||||
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()
|
||||
})
|
||||
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):
|
||||
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"]
|
||||
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']
|
||||
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(
|
||||
{
|
||||
"repository": repo_name,
|
||||
"path": file_path,
|
||||
"type": "directory",
|
||||
"contents": files,
|
||||
}
|
||||
)
|
||||
return web.json_response({
|
||||
"repository": repo_name,
|
||||
"path": file_path,
|
||||
"type": "directory",
|
||||
"contents": files
|
||||
})
|
||||
else:
|
||||
try:
|
||||
with open(file_full_path) as f:
|
||||
with open(file_full_path, 'r') 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):
|
||||
request["username"]
|
||||
repository_path = request["repository_path"]
|
||||
username = 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
|
||||
|
||||
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"
|
||||
@ -489,58 +435,48 @@ 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(text=f"Git error: {stderr.decode()}", status=500)
|
||||
return web.Response(
|
||||
body=stdout, content_type=f"application/x-{service}-result"
|
||||
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:
|
||||
|
@ -1,16 +0,0 @@
|
||||
from IPython import start_ipython
|
||||
|
||||
from snek.app import Application
|
||||
|
||||
|
||||
class Shell:
|
||||
def __init__(self, db_path):
|
||||
self.app = Application(db_path=f"sqlite:///{db_path}")
|
||||
|
||||
async def maintenance(self):
|
||||
await self.app.services.container.maintenance()
|
||||
await self.app.services.channel_message.maintenance()
|
||||
|
||||
def run(self):
|
||||
ns = {"app": self.app, "maintenance": self.maintenance}
|
||||
start_ipython(argv=[], user_ns=ns)
|
@ -2,28 +2,28 @@ import aiohttp
|
||||
|
||||
ENABLED = False
|
||||
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
from aiohttp import web
|
||||
|
||||
import sqlite3
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import dataset
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
import json
|
||||
|
||||
queue = asyncio.Queue()
|
||||
|
||||
|
||||
class State:
|
||||
do_not_sync = False
|
||||
|
||||
do_not_sync = False
|
||||
|
||||
async def sync_service(app):
|
||||
if not ENABLED:
|
||||
return
|
||||
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
|
||||
@ -31,7 +31,7 @@ async def sync_service(app):
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
State.do_not_sync = True
|
||||
State.do_not_sync = True
|
||||
app.db.execute(*data)
|
||||
app.db.commit()
|
||||
State.do_not_sync = False
|
||||
@ -41,25 +41,21 @@ async def sync_service(app):
|
||||
await app.services.socket.broadcast_event()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
# print(f"Received: {msg.data}")
|
||||
pass
|
||||
#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):
|
||||
@ -67,7 +63,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
|
||||
@ -75,18 +71,17 @@ 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
|
||||
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
|
||||
|
||||
@ -94,32 +89,28 @@ 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['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)
|
||||
|
@ -1,30 +1,25 @@
|
||||
import asyncssh
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import asyncssh
|
||||
|
||||
global _app
|
||||
|
||||
global _app
|
||||
|
||||
def set_app(app):
|
||||
global _app
|
||||
_app = 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)
|
||||
@ -35,22 +30,20 @@ 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):
|
||||
set_app(app)
|
||||
async def start_ssh_server(app,host,port):
|
||||
set_app(app)
|
||||
logger.info("Starting SFTP server setup")
|
||||
|
||||
|
||||
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
||||
host_key_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
try:
|
||||
@ -70,12 +63,14 @@ 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:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to start SFTP server. Already running.")
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
||||
import { Schedule } from "./schedule.js";
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
import { Socket } from "./socket.js";
|
||||
import { Njet } from "./njet.js";
|
||||
|
||||
export class RESTClient {
|
||||
debug = false;
|
||||
|
||||
@ -174,15 +174,7 @@ 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);
|
||||
}
|
||||
|
@ -9,10 +9,6 @@ html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
padding: 50px;
|
||||
height: auto;
|
||||
@ -144,7 +140,7 @@ footer {
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -225,55 +221,6 @@ 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%;
|
||||
}
|
||||
@ -368,7 +315,7 @@ input[type="text"], .chat-input textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
|
||||
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
|
||||
.time {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
@ -595,31 +542,7 @@ dialog .dialog-button.secondary {
|
||||
dialog .dialog-button.secondary:hover {
|
||||
background-color: #f0b84c;
|
||||
}
|
||||
dialog .dialog-button.primary:disabled,
|
||||
dialog .dialog-button.primary[aria-disabled="true"] {
|
||||
/* slightly darker + lower saturation of the live colour */
|
||||
background-color: #70321e; /* muted burnt orange */
|
||||
color: #bfbfbf; /* light grey text */
|
||||
|
||||
opacity: .55; /* unified fade */
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- SECONDARY (yellow) ---------- */
|
||||
dialog .dialog-button.secondary:disabled,
|
||||
dialog .dialog-button.secondary[aria-disabled="true"] {
|
||||
background-color: #6c5619; /* muted mustard */
|
||||
color: #bfbfbf;
|
||||
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
dialog .dialog-button:disabled:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.embed-url-link {
|
||||
display: flex;
|
||||
@ -661,8 +584,4 @@ dialog .dialog-button:disabled:focus {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
th {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
@ -1,40 +1,30 @@
|
||||
import { app } from "./app.js";
|
||||
import { NjetComponent,eventBus } from "./njet.js";
|
||||
import { FileUploadGrid } from "./file-upload-grid.js";
|
||||
import { app } from "../app.js";
|
||||
|
||||
class ChatInputComponent extends NjetComponent {
|
||||
class ChatInputComponent extends HTMLElement {
|
||||
autoCompletions = {
|
||||
"example 1": () => {},
|
||||
"example 2": () => {},
|
||||
};
|
||||
|
||||
}
|
||||
hiddenCompletions = {
|
||||
"/starsRender": () => {
|
||||
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
|
||||
},
|
||||
"/leet": () => {
|
||||
this.value = this.textToLeet(this.value);
|
||||
this._leetSpeak = !this._leetSpeak;
|
||||
},
|
||||
"/l33t": () => {
|
||||
this._leetSpeakAdvanced = !this._leetSpeakAdvanced;
|
||||
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
||||
}
|
||||
};
|
||||
|
||||
users = [];
|
||||
textarea = null;
|
||||
_value = "";
|
||||
lastUpdateEvent = null;
|
||||
queuedMessage = null;
|
||||
lastMessagePromise = null;
|
||||
_leetSpeak = false;
|
||||
_leetSpeakAdvanced = false;
|
||||
}
|
||||
users = []
|
||||
textarea = null
|
||||
_value = ""
|
||||
lastUpdateEvent = null
|
||||
previousValue = ""
|
||||
lastChange = null
|
||||
changed = false
|
||||
constructor() {
|
||||
super();
|
||||
this.lastUpdateEvent = new Date();
|
||||
this.textarea = document.createElement("textarea");
|
||||
this.textarea.classList.add("chat-input-textarea");
|
||||
this.value = this.getAttribute("value") || "";
|
||||
this.previousValue = this.value;
|
||||
this.lastChange = new Date();
|
||||
this.changed = false;
|
||||
}
|
||||
|
||||
get value() {
|
||||
@ -42,27 +32,24 @@ class ChatInputComponent extends NjetComponent {
|
||||
}
|
||||
|
||||
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(input) {
|
||||
resolveAutoComplete() {
|
||||
let count = 0;
|
||||
let value = null;
|
||||
|
||||
for (const key of Object.keys(this.allAutoCompletions)) {
|
||||
if (key.startsWith(input.split(" ", 1)[0])) {
|
||||
if (value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.keys(this.allAutoCompletions).forEach((key) => {
|
||||
if (key.startsWith(this.value.split(" ")[0])) {
|
||||
count++;
|
||||
value = key;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
if (count == 1) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
@ -71,213 +58,125 @@ class ChatInputComponent extends NjetComponent {
|
||||
|
||||
focus() {
|
||||
this.textarea.focus();
|
||||
}
|
||||
getAuthors(){
|
||||
let authors = []
|
||||
for (let i = 0; i < this.users.length; i++) {
|
||||
authors.push(this.users[i].username)
|
||||
authors.push(this.users[i].nick)
|
||||
}
|
||||
return authors
|
||||
|
||||
}
|
||||
extractMentions(text) {
|
||||
const regex = /@([a-zA-Z0-9_-]+)/g;
|
||||
const mentions = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
mentions.push(match[1]);
|
||||
}
|
||||
|
||||
getAuthors() {
|
||||
return this.users.flatMap((user) => [user.username, user.nick]);
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
matchMentionsToAuthors(mentions, authors) {
|
||||
return mentions.map(mention => {
|
||||
let closestAuthor = null;
|
||||
let minDistance = Infinity;
|
||||
const lowerMention = mention.toLowerCase();
|
||||
|
||||
authors.forEach(author => {
|
||||
const lowerAuthor = author.toLowerCase();
|
||||
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||
|
||||
|
||||
extractMentions(text) {
|
||||
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
|
||||
}
|
||||
|
||||
matchMentionsToAuthors(mentions, authors) {
|
||||
return mentions.map(mention => {
|
||||
let closestAuthor = null;
|
||||
let minDistance = Infinity;
|
||||
const lowerMention = mention.toLowerCase();
|
||||
|
||||
authors.forEach(author => {
|
||||
const lowerAuthor = author.toLowerCase();
|
||||
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||
|
||||
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
|
||||
distance += 10;
|
||||
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
|
||||
distance += 10
|
||||
}
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestAuthor = author;
|
||||
}
|
||||
});
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestAuthor = author;
|
||||
|
||||
}
|
||||
|
||||
return { mention, closestAuthor, distance: minDistance };
|
||||
});
|
||||
|
||||
return { mention, closestAuthor, distance: minDistance };
|
||||
});
|
||||
}
|
||||
levenshteinDistance(a, b) {
|
||||
const matrix = [];
|
||||
|
||||
// Initialize the first row and column
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
levenshteinDistance(a, b) {
|
||||
const matrix = [];
|
||||
|
||||
// Initialize the first row and column
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
// Fill in the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // Deletion
|
||||
matrix[i][j - 1] + 1, // Insertion
|
||||
matrix[i - 1][j - 1] + 1 // Substitution
|
||||
);
|
||||
}
|
||||
// Fill in the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // Deletion
|
||||
matrix[i][j - 1] + 1, // Insertion
|
||||
matrix[i - 1][j - 1] + 1 // Substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
replaceMentionsWithAuthors(text) {
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
|
||||
|
||||
replaceMentionsWithAuthors(text) {
|
||||
const authors = this.getAuthors();
|
||||
const mentions = this.extractMentions(text);
|
||||
|
||||
const matches = this.matchMentionsToAuthors(mentions, authors);
|
||||
let updatedText = text;
|
||||
matches.forEach(({ mention, closestAuthor }) => {
|
||||
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
||||
const mentions = this.extractMentions(text);
|
||||
|
||||
const matches = this.matchMentionsToAuthors(mentions, authors);
|
||||
let updatedText = text;
|
||||
matches.forEach(({ mention, closestAuthor }) => {
|
||||
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
||||
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
||||
});
|
||||
|
||||
return updatedText;
|
||||
}
|
||||
textToLeet(text) {
|
||||
// L33t speak character mapping
|
||||
const leetMap = {
|
||||
'a': '4',
|
||||
'A': '4',
|
||||
'e': '3',
|
||||
'E': '3',
|
||||
'i': '1',
|
||||
'I': '1',
|
||||
'o': '0',
|
||||
'O': '0',
|
||||
's': '5',
|
||||
'S': '5',
|
||||
't': '7',
|
||||
'T': '7',
|
||||
'l': '1',
|
||||
'L': '1',
|
||||
'g': '9',
|
||||
'G': '9',
|
||||
'b': '6',
|
||||
'B': '6',
|
||||
'z': '2',
|
||||
'Z': '2'
|
||||
};
|
||||
});
|
||||
|
||||
// Convert text to l33t speak
|
||||
return text.split('').map(char => {
|
||||
return leetMap[char] || char;
|
||||
}).join('');
|
||||
return updatedText;
|
||||
}
|
||||
|
||||
|
||||
// Advanced version with random character selection
|
||||
textToLeetAdvanced(text) {
|
||||
const leetMap = {
|
||||
'a': ['4', '@', '/\\'],
|
||||
'A': ['4', '@', '/\\'],
|
||||
'e': ['3', '€'],
|
||||
'E': ['3', '€'],
|
||||
'i': ['1', '!', '|'],
|
||||
'I': ['1', '!', '|'],
|
||||
'o': ['0', '()'],
|
||||
'O': ['0', '()'],
|
||||
's': ['5', '$'],
|
||||
'S': ['5', '$'],
|
||||
't': ['7', '+'],
|
||||
'T': ['7', '+'],
|
||||
'l': ['1', '|'],
|
||||
'L': ['1', '|'],
|
||||
'g': ['9', '6'],
|
||||
'G': ['9', '6'],
|
||||
'b': ['6', '|3'],
|
||||
'B': ['6', '|3'],
|
||||
'z': ['2'],
|
||||
'Z': ['2'],
|
||||
'h': ['#', '|-|'],
|
||||
'H': ['#', '|-|'],
|
||||
'n': ['|\\|'],
|
||||
'N': ['|\\|'],
|
||||
'm': ['|\\/|'],
|
||||
'M': ['|\\/|'],
|
||||
'w': ['\\/\\/'],
|
||||
'W': ['\\/\\/'],
|
||||
'v': ['\\/', 'V'],
|
||||
'V': ['\\/', 'V'],
|
||||
'u': ['|_|'],
|
||||
'U': ['|_|'],
|
||||
'r': ['|2'],
|
||||
'R': ['|2'],
|
||||
'f': ['|='],
|
||||
'F': ['|='],
|
||||
'd': ['|)'],
|
||||
'D': ['|)'],
|
||||
'c': ['(', '['],
|
||||
'C': ['(', '['],
|
||||
'k': ['|<'],
|
||||
'K': ['|<'],
|
||||
'p': ['|>'],
|
||||
'P': ['|>'],
|
||||
'x': ['><'],
|
||||
'X': ['><'],
|
||||
'y': ['`/'],
|
||||
'Y': ['`/']
|
||||
};
|
||||
|
||||
return text.split('').map(char => {
|
||||
const options = leetMap[char];
|
||||
if (options) {
|
||||
return options[Math.floor(Math.random() * options.length)];
|
||||
}
|
||||
return char;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
async connectedCallback() {
|
||||
this.user = null;
|
||||
this.user = null
|
||||
app.rpc.getUser(null).then((user) => {
|
||||
this.user = user;
|
||||
});
|
||||
this.user=user
|
||||
})
|
||||
|
||||
this.liveType = this.getAttribute("live-type") == "true";
|
||||
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||
|
||||
const me = this;
|
||||
this.liveType = this.getAttribute("live-type") === "true";
|
||||
this.liveTypeInterval =
|
||||
parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||
this.channelUid = this.getAttribute("channel");
|
||||
|
||||
app.rpc.getRecentUsers(this.channelUid).then(users => {
|
||||
this.users = users;
|
||||
});
|
||||
this.messageUid = null;
|
||||
|
||||
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
||||
this.users = users
|
||||
})
|
||||
this.messageUid = null;
|
||||
|
||||
this.classList.add("chat-input");
|
||||
|
||||
this.fileUploadGrid = new FileUploadGrid();
|
||||
this.fileUploadGrid.setAttribute("channel", this.channelUid);
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
this.appendChild(this.fileUploadGrid);
|
||||
|
||||
this.textarea.setAttribute("placeholder", "Type a message...");
|
||||
this.textarea.setAttribute("rows", "2");
|
||||
|
||||
this.appendChild(this.textarea);
|
||||
this.ttsButton = document.createElement("stt-button");
|
||||
|
||||
this.snekSpeaker = document.createElement("snek-speaker");
|
||||
this.appendChild(this.snekSpeaker);
|
||||
|
||||
this.ttsButton.addEventListener("click", (e) => {
|
||||
this.snekSpeaker.enable()
|
||||
});
|
||||
|
||||
this.appendChild(this.ttsButton);
|
||||
this.uploadButton = document.createElement("upload-button");
|
||||
this.uploadButton.setAttribute("channel", this.channelUid);
|
||||
this.uploadButton.addEventListener("upload", (e) => {
|
||||
@ -286,156 +185,189 @@ textToLeetAdvanced(text) {
|
||||
this.uploadButton.addEventListener("uploaded", (e) => {
|
||||
this.dispatchEvent(new CustomEvent("uploaded", e));
|
||||
});
|
||||
this.uploadButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.fileUploadGrid.openFileDialog();
|
||||
});
|
||||
this.subscribe("file-uploading", (e) => {
|
||||
this.fileUploadGrid.style.display = "block";
|
||||
this.uploadButton.style.display = "none";
|
||||
this.textarea.style.display = "none";
|
||||
})
|
||||
|
||||
this.appendChild(this.uploadButton);
|
||||
|
||||
this.textarea.addEventListener("blur", () => {
|
||||
this.updateFromInput(this.value, true).then(
|
||||
this.updateFromInput("")
|
||||
)
|
||||
});
|
||||
|
||||
this.subscribe("file-uploads-done", (data)=>{
|
||||
this.textarea.style.display = "block";
|
||||
this.uploadButton.style.display = "block";
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
let msg =data.reduce((message, file) => {
|
||||
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
|
||||
}, '');
|
||||
app.rpc.sendMessage(this.channelUid, msg, true);
|
||||
});
|
||||
|
||||
|
||||
this.textarea.addEventListener("change",(e)=>{
|
||||
this.value = this.textarea.value;
|
||||
this.updateFromInput(e.target.value);
|
||||
})
|
||||
this.textarea.addEventListener("keyup", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
const message = this.replaceMentionsWithAuthors(this.value);
|
||||
this.value = "";
|
||||
e.target.value = "";
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
|
||||
if (autoCompletionHandler) {
|
||||
autoCompletionHandler();
|
||||
this.value = "";
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.finalizeMessage(this.messageUid);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromInput(e.target.value);
|
||||
this.value = e.target.value;
|
||||
this.changed = true;
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.textarea.addEventListener("keydown", (e) => {
|
||||
this.value = e.target.value;
|
||||
|
||||
let autoCompletion = null;
|
||||
let autoCompletion = null;
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
autoCompletion = this.resolveAutoComplete(this.value);
|
||||
autoCompletion = this.resolveAutoComplete();
|
||||
if (autoCompletion) {
|
||||
e.target.value = autoCompletion;
|
||||
this.value = autoCompletion;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.repeat) {
|
||||
this.updateFromInput(e.target.value);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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.reduce((message, file) => {
|
||||
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||
}, '');
|
||||
app.rpc.sendMessage(this.channelUid, message, true);
|
||||
let message = "";
|
||||
e.detail.files.forEach((file) => {
|
||||
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||
});
|
||||
app.rpc.sendMessage(this.channelUid, message,true);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(()=>{
|
||||
this.focus();
|
||||
}, 1000);
|
||||
},1000)
|
||||
}
|
||||
|
||||
trackSecondsBetweenEvents(event1Time, event2Time) {
|
||||
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
||||
return millisecondsDifference / 1000;
|
||||
}
|
||||
|
||||
isSubsequence(s, t) {
|
||||
let i = 0, j = 0;
|
||||
while (i < s.length && j < t.length) {
|
||||
if (s[i] === t[j]) {
|
||||
i++;
|
||||
let i = 0, j = 0;
|
||||
while (i < s.length && j < t.length) {
|
||||
if (s[i] === t[j]) {
|
||||
i++;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
j++;
|
||||
return i === s.length;
|
||||
}
|
||||
|
||||
|
||||
newMessage() {
|
||||
if (!this.messageUid) {
|
||||
this.messageUid = "?";
|
||||
}
|
||||
|
||||
this.value = this.replaceMentionsWithAuthors(this.value);
|
||||
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
|
||||
if (this.liveType) {
|
||||
this.messageUid = uid;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateMessage() {
|
||||
if (this.value[0] == "/") {
|
||||
return false;
|
||||
}
|
||||
if (!this.messageUid) {
|
||||
this.newMessage();
|
||||
return false;
|
||||
}
|
||||
if (this.messageUid === "?") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof app !== "undefined" &&
|
||||
app.rpc &&
|
||||
typeof app.rpc.updateMessageText === "function"
|
||||
) {
|
||||
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
|
||||
}
|
||||
return i === s.length;
|
||||
}
|
||||
|
||||
flagTyping() {
|
||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
|
||||
updateStatus() {
|
||||
if (this.liveType) {
|
||||
return;
|
||||
}
|
||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
|
||||
this.lastUpdateEvent = new Date();
|
||||
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
|
||||
if (
|
||||
typeof app !== "undefined" &&
|
||||
app.rpc &&
|
||||
typeof app.rpc.set_typing === "function"
|
||||
) {
|
||||
app.rpc.set_typing(this.channelUid, this.user.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalizeMessage(messageUid) {
|
||||
let value = this.value;
|
||||
value = this.replaceMentionsWithAuthors(value)
|
||||
if(this._leetSpeak){
|
||||
value = this.textToLeet(value);
|
||||
}else if(this._leetSpeakAdvanced){
|
||||
value = this.textToLeetAdvanced(value);
|
||||
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();
|
||||
}
|
||||
app.rpc.sendMessage(this.channelUid, value , true);
|
||||
this.value = "";
|
||||
this.messageUid = null;
|
||||
this.queuedMessage = null;
|
||||
this.lastMessagePromise = null;
|
||||
}
|
||||
|
||||
|
||||
this.previousValue = this.value;
|
||||
|
||||
updateFromInput(value, isFinal = false) {
|
||||
if (this.liveType && expired) {
|
||||
this.value = "";
|
||||
this.previousValue = "";
|
||||
this.messageUid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
|
||||
this.flagTyping();
|
||||
|
||||
if (this.liveType && value[0] !== "/") {
|
||||
const messageText = this.replaceMentionsWithAuthors(value);
|
||||
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
|
||||
return this.messageUid;
|
||||
if (changed) {
|
||||
if (this.liveType) {
|
||||
this.updateMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(channelUid, value, is_final) {
|
||||
return await app.rpc.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);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-input", ChatInputComponent);
|
||||
|
||||
|
@ -1,113 +0,0 @@
|
||||
import { app } from "./app.js";
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
|
||||
export class Container extends EventHandler{
|
||||
status = "unknown"
|
||||
cpus = 0
|
||||
memory = "0m"
|
||||
image = "unknown:unknown"
|
||||
name = null
|
||||
channelUid = null
|
||||
log = false
|
||||
bytesSent = 0
|
||||
bytesReceived = 0
|
||||
_container = null
|
||||
render(el){
|
||||
if(this._container == null){
|
||||
this._container = el
|
||||
this.terminal.open(this._container)
|
||||
|
||||
|
||||
this.terminal.onData(data => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
//this.refresh()
|
||||
this.terminal.focus()
|
||||
}
|
||||
refresh(){
|
||||
//this._fitAddon.fit();
|
||||
this.ws.send("\x0C");
|
||||
}
|
||||
toggle(){
|
||||
this._container.classList.toggle("hidden")
|
||||
this.refresh()
|
||||
}
|
||||
fit(){
|
||||
this._fitAddon.fit();
|
||||
|
||||
}
|
||||
constructor(channelUid,log){
|
||||
super()
|
||||
this.terminal = new Terminal({ cursorBlink: true ,theme: {
|
||||
background: 'rgba(0, 0, 0, 0)', // Fully transparent
|
||||
}
|
||||
});
|
||||
this._fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this._fitAddon);
|
||||
window.addEventListener("resize", () => this._fitAddon.fit());
|
||||
this.log = log ? true : false
|
||||
this.channelUid = channelUid
|
||||
this.update()
|
||||
this.addEventListener("stdout", (data) => {
|
||||
this.bytesReceived += data.length
|
||||
if(this.log){
|
||||
console.log(`Container ${this.name}: ${data}`)
|
||||
}
|
||||
const fixedData = new Uint8Array(data);
|
||||
this.terminal.write(new TextDecoder().decode(fixedData));
|
||||
|
||||
})
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/container/sock/${this.channelUid}.json`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
|
||||
|
||||
this.ws.binaryType = "arraybuffer"; // Support binary data
|
||||
this.ws.onmessage = (event) => {
|
||||
this.emit("stdout", event.data)
|
||||
}
|
||||
this.ws.onopen = () => {
|
||||
this.refresh()
|
||||
}
|
||||
window.container = this
|
||||
|
||||
}
|
||||
async start(){
|
||||
const result = await app.rpc.startContainer(this.channelUid)
|
||||
await this.refresh()
|
||||
return result && this.status == 'running'
|
||||
}
|
||||
async stop(){
|
||||
const result = await app.rpc.stopContainer(this.channelUid)
|
||||
await this.refresh()
|
||||
return result && this.status == 'stopped'
|
||||
}
|
||||
async write(data){
|
||||
await this.ws.send(data)
|
||||
this.bytesSent += data.length
|
||||
return true
|
||||
}
|
||||
async update(){
|
||||
|
||||
const container = await app.rpc.getContainer(this.channelUid)
|
||||
this.status = container["status"]
|
||||
this.cpus = container["cpus"]
|
||||
this.memory = container["memory"]
|
||||
this.image = container["image"]
|
||||
this.name = container["name"]
|
||||
}
|
||||
|
||||
}
|
||||
/*
|
||||
window.getContainer = function(){
|
||||
return new Container(app.channelUid)
|
||||
}*/
|
||||
|
@ -1,32 +1,13 @@
|
||||
import { NjetComponent } from "/njet.js";
|
||||
class WebTerminal extends NjetComponent {
|
||||
|
||||
commands = {
|
||||
"clear": () => {
|
||||
this.outputEl.innerHTML = "";
|
||||
return "";
|
||||
},
|
||||
"help": () => {
|
||||
return "Available commands: help, clear, date";
|
||||
},
|
||||
"date": () => {
|
||||
return new Date().toString();
|
||||
}
|
||||
};
|
||||
help = {
|
||||
"clear": "Clear the terminal",
|
||||
"help": "Show available commands",
|
||||
"date": "Show the current date"
|
||||
};
|
||||
|
||||
|
||||
|
||||
class DumbTerminal extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
||||
this.innerHTML = `
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
.web-terminal {
|
||||
:host {
|
||||
--terminal-bg: #111;
|
||||
--terminal-fg: #0f0;
|
||||
--terminal-accent: #0ff;
|
||||
@ -42,7 +23,7 @@ class WebTerminal extends NjetComponent {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.web-terminal-output {
|
||||
.output {
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
@ -56,7 +37,7 @@ class WebTerminal extends NjetComponent {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.web-terminal-input {
|
||||
input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--terminal-fg);
|
||||
@ -76,22 +57,21 @@ class WebTerminal extends NjetComponent {
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
<div class="web-terminal">
|
||||
<div class="web-terminal-output" id="output"></div>
|
||||
|
||||
<div class="output" id="output"></div>
|
||||
<div class="input-line">
|
||||
<span class="prompt">></span>
|
||||
<input type="text" class="web-terminal-input" id="input" autocomplete="off" autofocus />
|
||||
</div>
|
||||
<span class="prompt">></span>
|
||||
<input type="text" id="input" autocomplete="off" autofocus />
|
||||
</div>
|
||||
`;
|
||||
this.container = this.querySelector(".web-terminal");
|
||||
this.outputEl = this.querySelector(".web-terminal-output");
|
||||
this.inputEl = this.querySelector(".web-terminal-input");
|
||||
|
||||
this.outputEl = this.shadowRoot.getElementById("output");
|
||||
this.inputEl = this.shadowRoot.getElementById("input");
|
||||
|
||||
this.history = [];
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.inputEl.addEventListener("keyup", (e) => this.onKeyDown(e));
|
||||
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
@ -136,89 +116,17 @@ class WebTerminal extends NjetComponent {
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
|
||||
parseCommand(input) {
|
||||
const args = [];
|
||||
let current = '';
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inTemplate = false; // For ${...}
|
||||
let templateBuffer = '';
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
|
||||
// Handle template expressions
|
||||
if (!inSingleQuote && !inDoubleQuote && char === '$' && input[i + 1] === '{') {
|
||||
inTemplate = true;
|
||||
i++; // Skip '{'
|
||||
templateBuffer = '${';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTemplate) {
|
||||
templateBuffer += char;
|
||||
if (char === '}') {
|
||||
// End of template
|
||||
args.push(eval(templateBuffer));
|
||||
inTemplate = false;
|
||||
templateBuffer = '';
|
||||
}
|
||||
continue; // Continue to next char
|
||||
}
|
||||
|
||||
// Handle quotes
|
||||
if (char === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
continue; // Skip quote
|
||||
}
|
||||
|
||||
if (char === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
continue; // Skip quote
|
||||
}
|
||||
|
||||
// Handle spaces outside quotes and templates
|
||||
if (char === ' ' && !inSingleQuote && !inDoubleQuote) {
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
executeScript(script) {
|
||||
const scriptElement = document.createElement("script");
|
||||
scriptElement.textContent = script;
|
||||
this.appendChild(scriptElement);
|
||||
}
|
||||
mockExecute(command) {
|
||||
let args;
|
||||
try {
|
||||
args = this.parseCommand(command);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
console.info({ef:this})
|
||||
console.info({af:this})
|
||||
console.info({gf:args})
|
||||
let cmdName = args.shift();
|
||||
let commandHandler = this.commands[cmdName];
|
||||
if (commandHandler) {
|
||||
return commandHandler.apply(this, args);
|
||||
}
|
||||
try {
|
||||
// Try to eval as JS
|
||||
return this.executeScript(command);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
switch (command.trim()) {
|
||||
case "help":
|
||||
return "Available commands: help, clear, date";
|
||||
case "date":
|
||||
return new Date().toString();
|
||||
case "clear":
|
||||
this.outputEl.innerHTML = "";
|
||||
return "";
|
||||
default:
|
||||
return `Unknown command: ${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,7 +134,7 @@ class WebTerminal extends NjetComponent {
|
||||
* Static method to create a modal dialog with the terminal
|
||||
* @returns {HTMLDialogElement}
|
||||
*/
|
||||
show() {
|
||||
static createModal() {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-backdrop">
|
||||
@ -239,12 +147,4 @@ class WebTerminal extends NjetComponent {
|
||||
}
|
||||
}
|
||||
|
||||
window.showTerm = function (options) {
|
||||
const term = new WebTerminal(options);
|
||||
term.show();
|
||||
return term;
|
||||
}
|
||||
|
||||
customElements.define("web-terminal", WebTerminal);
|
||||
export { WebTerminal };
|
||||
|
||||
|
@ -1,494 +0,0 @@
|
||||
import { NjetComponent } from "/njet.js"
|
||||
|
||||
class NjetEditor extends NjetComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
#editor {
|
||||
padding: 1rem;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
height: calc(100% - 30px);
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
caret-color: #fff;
|
||||
}
|
||||
|
||||
#editor.insert-mode {
|
||||
caret-color: #4ec9b0;
|
||||
}
|
||||
|
||||
#editor.visual-mode {
|
||||
caret-color: #c586c0;
|
||||
}
|
||||
|
||||
#editor::selection {
|
||||
background: #264f78;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
background: #007acc;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#mode-indicator {
|
||||
text-transform: uppercase;
|
||||
margin-right: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#command-line {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0.3rem 1rem;
|
||||
background: #2d2d2d;
|
||||
color: #d4d4d4;
|
||||
display: none;
|
||||
font-family: inherit;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#command-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.visual-selection {
|
||||
background: #264f78 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
this.editor = document.createElement('div');
|
||||
this.editor.id = 'editor';
|
||||
this.editor.contentEditable = true;
|
||||
this.editor.spellcheck = false;
|
||||
this.editor.innerText = `Welcome to VimEditor Component
|
||||
Line 2 here
|
||||
Another line
|
||||
Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
|
||||
|
||||
this.cmdLine = document.createElement('div');
|
||||
this.cmdLine.id = 'command-line';
|
||||
|
||||
const cmdPrompt = document.createElement('span');
|
||||
cmdPrompt.textContent = ':';
|
||||
|
||||
this.cmdInput = document.createElement('input');
|
||||
this.cmdInput.id = 'command-input';
|
||||
this.cmdInput.type = 'text';
|
||||
|
||||
this.cmdLine.append(cmdPrompt, this.cmdInput);
|
||||
|
||||
this.statusBar = document.createElement('div');
|
||||
this.statusBar.id = 'status-bar';
|
||||
|
||||
this.modeIndicator = document.createElement('span');
|
||||
this.modeIndicator.id = 'mode-indicator';
|
||||
this.modeIndicator.textContent = 'NORMAL';
|
||||
|
||||
this.statusBar.appendChild(this.modeIndicator);
|
||||
|
||||
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
|
||||
|
||||
this.mode = 'normal';
|
||||
this.keyBuffer = '';
|
||||
this.lastDeletedLine = '';
|
||||
this.yankedLine = '';
|
||||
this.visualStartOffset = null;
|
||||
this.visualEndOffset = null;
|
||||
|
||||
// Bind event handlers
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
this.handleCmdKeydown = this.handleCmdKeydown.bind(this);
|
||||
this.updateVisualSelection = this.updateVisualSelection.bind(this);
|
||||
|
||||
this.editor.addEventListener('keydown', this.handleKeydown);
|
||||
this.cmdInput.addEventListener('keydown', this.handleCmdKeydown);
|
||||
this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.editor.focus();
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
this.modeIndicator.textContent = mode.toUpperCase();
|
||||
|
||||
// Update editor classes
|
||||
this.editor.classList.remove('insert-mode', 'visual-mode', 'normal-mode');
|
||||
this.editor.classList.add(`${mode}-mode`);
|
||||
|
||||
if (mode === 'visual') {
|
||||
this.visualStartOffset = this.getCaretOffset();
|
||||
this.editor.addEventListener('selectionchange', this.updateVisualSelection);
|
||||
} else {
|
||||
this.clearVisualSelection();
|
||||
this.editor.removeEventListener('selectionchange', this.updateVisualSelection);
|
||||
}
|
||||
|
||||
if (mode === 'command') {
|
||||
this.cmdLine.style.display = 'block';
|
||||
this.cmdInput.value = '';
|
||||
this.cmdInput.focus();
|
||||
} else {
|
||||
this.cmdLine.style.display = 'none';
|
||||
if (mode !== 'insert') {
|
||||
// Keep focus on editor for all non-insert modes
|
||||
this.editor.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateVisualSelection() {
|
||||
if (this.mode !== 'visual') return;
|
||||
this.visualEndOffset = this.getCaretOffset();
|
||||
}
|
||||
|
||||
clearVisualSelection() {
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel) sel.removeAllRanges();
|
||||
this.visualStartOffset = null;
|
||||
this.visualEndOffset = null;
|
||||
}
|
||||
|
||||
getCaretOffset() {
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(this.editor);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
return preCaretRange.toString().length;
|
||||
}
|
||||
|
||||
setCaretOffset(offset) {
|
||||
const textContent = this.editor.innerText;
|
||||
offset = Math.max(0, Math.min(offset, textContent.length));
|
||||
|
||||
const range = document.createRange();
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
const walker = document.createTreeWalker(
|
||||
this.editor,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let currentOffset = 0;
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const nodeLength = node.textContent.length;
|
||||
if (currentOffset + nodeLength >= offset) {
|
||||
range.setStart(node, offset - currentOffset);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
currentOffset += nodeLength;
|
||||
}
|
||||
|
||||
// If we couldn't find the position, set to end
|
||||
if (this.editor.lastChild) {
|
||||
range.selectNodeContents(this.editor.lastChild);
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
handleBeforeInput(e) {
|
||||
if (this.mode !== 'insert') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleCmdKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeCommand(this.cmdInput.value);
|
||||
this.setMode('normal');
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.setMode('normal');
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand(cmd) {
|
||||
const trimmedCmd = cmd.trim();
|
||||
|
||||
// Handle basic vim commands
|
||||
if (trimmedCmd === 'w' || trimmedCmd === 'write') {
|
||||
console.log('Save command (not implemented)');
|
||||
} else if (trimmedCmd === 'q' || trimmedCmd === 'quit') {
|
||||
console.log('Quit command (not implemented)');
|
||||
} else if (trimmedCmd === 'wq' || trimmedCmd === 'x') {
|
||||
console.log('Save and quit command (not implemented)');
|
||||
} else if (/^\d+$/.test(trimmedCmd)) {
|
||||
// Go to line number
|
||||
const lineNum = parseInt(trimmedCmd, 10) - 1;
|
||||
this.goToLine(lineNum);
|
||||
}
|
||||
}
|
||||
|
||||
goToLine(lineNum) {
|
||||
const lines = this.editor.innerText.split('\n');
|
||||
if (lineNum < 0 || lineNum >= lines.length) return;
|
||||
|
||||
let offset = 0;
|
||||
for (let i = 0; i < lineNum; i++) {
|
||||
offset += lines[i].length + 1;
|
||||
}
|
||||
this.setCaretOffset(offset);
|
||||
}
|
||||
|
||||
getCurrentLineInfo() {
|
||||
const text = this.editor.innerText;
|
||||
const caretPos = this.getCaretOffset();
|
||||
const lines = text.split('\n');
|
||||
|
||||
let charCount = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (caretPos <= charCount + lines[i].length) {
|
||||
return {
|
||||
lineIndex: i,
|
||||
lines: lines,
|
||||
lineStartOffset: charCount,
|
||||
positionInLine: caretPos - charCount
|
||||
};
|
||||
}
|
||||
charCount += lines[i].length + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
lineIndex: lines.length - 1,
|
||||
lines: lines,
|
||||
lineStartOffset: charCount - lines[lines.length - 1].length - 1,
|
||||
positionInLine: 0
|
||||
};
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (this.mode === 'insert') {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.setMode('normal');
|
||||
// Move cursor one position left (vim behavior)
|
||||
const offset = this.getCaretOffset();
|
||||
if (offset > 0) {
|
||||
this.setCaretOffset(offset - 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'command') {
|
||||
return; // Command mode input is handled by cmdInput
|
||||
}
|
||||
|
||||
if (this.mode === 'visual') {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow movement in visual mode
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
return; // Let default behavior handle selection
|
||||
}
|
||||
|
||||
if (e.key === 'y') {
|
||||
e.preventDefault();
|
||||
// Yank selected text
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.yankedLine = sel.toString();
|
||||
}
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'd' || e.key === 'x') {
|
||||
e.preventDefault();
|
||||
// Delete selected text
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.lastDeletedLine = sel.toString();
|
||||
document.execCommand('delete');
|
||||
}
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode handling
|
||||
e.preventDefault();
|
||||
|
||||
// Special keys that should be handled immediately
|
||||
if (e.key === 'Escape') {
|
||||
this.keyBuffer = '';
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build key buffer for commands
|
||||
this.keyBuffer += e.key;
|
||||
|
||||
const lineInfo = this.getCurrentLineInfo();
|
||||
const { lineIndex, lines, lineStartOffset, positionInLine } = lineInfo;
|
||||
|
||||
// Process commands
|
||||
switch (this.keyBuffer) {
|
||||
case 'i':
|
||||
this.keyBuffer = '';
|
||||
this.setMode('insert');
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(this.getCaretOffset() + 1);
|
||||
this.setMode('insert');
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
this.keyBuffer = '';
|
||||
this.setMode('visual');
|
||||
break;
|
||||
|
||||
case ':':
|
||||
this.keyBuffer = '';
|
||||
this.setMode('command');
|
||||
break;
|
||||
|
||||
case 'yy':
|
||||
this.keyBuffer = '';
|
||||
this.yankedLine = lines[lineIndex];
|
||||
break;
|
||||
|
||||
case 'dd':
|
||||
this.keyBuffer = '';
|
||||
this.lastDeletedLine = lines[lineIndex];
|
||||
lines.splice(lineIndex, 1);
|
||||
if (lines.length === 0) lines.push('');
|
||||
this.editor.innerText = lines.join('\n');
|
||||
this.setCaretOffset(lineStartOffset);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
this.keyBuffer = '';
|
||||
const lineToPaste = this.yankedLine || this.lastDeletedLine;
|
||||
if (lineToPaste) {
|
||||
lines.splice(lineIndex + 1, 0, lineToPaste);
|
||||
this.editor.innerText = lines.join('\n');
|
||||
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case '0':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(lineStartOffset);
|
||||
break;
|
||||
|
||||
case '$':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(lineStartOffset + lines[lineIndex].length);
|
||||
break;
|
||||
|
||||
case 'gg':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(0);
|
||||
break;
|
||||
|
||||
case 'G':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(this.editor.innerText.length);
|
||||
break;
|
||||
|
||||
case 'h':
|
||||
case 'ArrowLeft':
|
||||
this.keyBuffer = '';
|
||||
const currentOffset = this.getCaretOffset();
|
||||
if (currentOffset > 0) {
|
||||
this.setCaretOffset(currentOffset - 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
this.keyBuffer = '';
|
||||
this.setCaretOffset(this.getCaretOffset() + 1);
|
||||
break;
|
||||
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
this.keyBuffer = '';
|
||||
if (lineIndex < lines.length - 1) {
|
||||
const nextLineStart = lineStartOffset + lines[lineIndex].length + 1;
|
||||
const nextLineLength = lines[lineIndex + 1].length;
|
||||
const newPosition = Math.min(positionInLine, nextLineLength);
|
||||
this.setCaretOffset(nextLineStart + newPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
this.keyBuffer = '';
|
||||
if (lineIndex > 0) {
|
||||
let prevLineStart = 0;
|
||||
for (let i = 0; i < lineIndex - 1; i++) {
|
||||
prevLineStart += lines[i].length + 1;
|
||||
}
|
||||
const prevLineLength = lines[lineIndex - 1].length;
|
||||
const newPosition = Math.min(positionInLine, prevLineLength);
|
||||
this.setCaretOffset(prevLineStart + newPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Clear buffer if it gets too long or contains invalid sequences
|
||||
if (this.keyBuffer.length > 2 ||
|
||||
(this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) {
|
||||
this.keyBuffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('njet-editor', NjetEditor);
|
||||
export { NjetEditor }
|
@ -3,15 +3,8 @@ export class EventHandler {
|
||||
this.subscribers = {};
|
||||
}
|
||||
|
||||
addEventListener(type, handler, { once = false } = {}) {
|
||||
addEventListener(type, handler) {
|
||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
handler = (...args) => {
|
||||
originalHandler(...args);
|
||||
this.removeEventListener(type, handler);
|
||||
};
|
||||
}
|
||||
this.subscribers[type].push(handler);
|
||||
}
|
||||
|
||||
@ -19,15 +12,4 @@ export class EventHandler {
|
||||
if (this.subscribers[type])
|
||||
this.subscribers[type].forEach((handler) => handler(...data));
|
||||
}
|
||||
|
||||
removeEventListener(type, handler) {
|
||||
if (!this.subscribers[type]) return;
|
||||
this.subscribers[type] = this.subscribers[type].filter(
|
||||
(h) => h !== handler
|
||||
);
|
||||
|
||||
if (this.subscribers[type].length === 0) {
|
||||
delete this.subscribers[type];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
9
src/snek/static/fa640.min.css
vendored
@ -1,6 +1,4 @@
|
||||
/* A <file-browser> custom element that talks to /api/files */
|
||||
import { NjetComponent } from "/njet.js";
|
||||
|
||||
class FileBrowser extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
@ -8,11 +6,9 @@ class FileBrowser extends HTMLElement {
|
||||
this.path = ""; // current virtual path ("" = ROOT)
|
||||
this.offset = 0; // pagination offset
|
||||
this.limit = 40; // items per request
|
||||
this.url = '/drive.json'
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.url = this.getAttribute("url") || this.url;
|
||||
this.path = this.getAttribute("path") || "";
|
||||
this.renderShell();
|
||||
this.load();
|
||||
@ -62,7 +58,7 @@ class FileBrowser extends HTMLElement {
|
||||
// ---------- Networking ----------------------------------------------
|
||||
async load() {
|
||||
const r = await fetch(
|
||||
this.url + `?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`
|
||||
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
|
||||
);
|
||||
if (!r.ok) {
|
||||
console.error(await r.text());
|
||||
|
@ -1,59 +0,0 @@
|
||||
.fug-root {
|
||||
background: #181818;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
/*min-height: 100vh;*/
|
||||
}
|
||||
.fug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 150px);
|
||||
gap: 20px;
|
||||
margin: 30px;
|
||||
}
|
||||
.fug-tile {
|
||||
background: #111;
|
||||
border: 2px solid #ff6600;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 8px 8px 8px;
|
||||
box-shadow: 0 0 4px #333;
|
||||
position: relative;
|
||||
}
|
||||
.fug-icon {
|
||||
width: 48px; height: 48px;
|
||||
margin-bottom: 8px;
|
||||
object-fit: cover;
|
||||
background: #222;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.fug-filename {
|
||||
word-break: break-all;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.95em;
|
||||
text-align: center;
|
||||
}
|
||||
.fug-progressbar {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: auto;
|
||||
}
|
||||
.fug-progress {
|
||||
height: 100%;
|
||||
background: #ffb200;
|
||||
transition: width 0.15s;
|
||||
width: 0%;
|
||||
}
|
||||
.fug-tile.fug-done {
|
||||
border-color: #0f0;
|
||||
}
|
||||
.fug-fileinput {
|
||||
margin: 24px;
|
||||
font-size: 1.1em;
|
||||
display: none;
|
||||
}
|
||||
|
@ -1,180 +0,0 @@
|
||||
import { NjetComponent, NjetDialog } from '/njet.js';
|
||||
|
||||
const FUG_ICONS = {
|
||||
file: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect x="8" y="8" width="32" height="40" rx="5" fill="white" stroke="%234af" stroke-width="2"/></svg>'
|
||||
};
|
||||
|
||||
class FileUploadGrid extends NjetComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this._grid = null;
|
||||
this._fileInput = null;
|
||||
this.channelUid = null ;
|
||||
this.uploadsDone = 0;
|
||||
this.uploadsStarted = 0;
|
||||
this.uploadResponses = [];
|
||||
}
|
||||
|
||||
openFileDialog() {
|
||||
if(this.isBusy){
|
||||
const dialog = new NjetDialog({
|
||||
title: 'Upload in progress',
|
||||
content: 'Please wait for the current upload to complete.',
|
||||
primaryButton: {
|
||||
text: 'OK',
|
||||
handler: function () {
|
||||
this.closest('njet-dialog').remove();
|
||||
}
|
||||
}
|
||||
})
|
||||
dialog.open();
|
||||
return false
|
||||
}
|
||||
|
||||
if (this._fileInput) {
|
||||
this._fileInput.value = ''; // Allow same file selection twice
|
||||
this._fileInput.click();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Root wrapper for styling
|
||||
this.classList.add('fug-root');
|
||||
// Clear previous (if rerendered)
|
||||
this.innerHTML = '';
|
||||
// Input
|
||||
this._fileInput = document.createElement('input');
|
||||
this._fileInput.type = 'file';
|
||||
this._fileInput.className = 'fug-fileinput';
|
||||
this._fileInput.multiple = true;
|
||||
// Grid
|
||||
this._grid = document.createElement('div');
|
||||
this._grid.className = 'fug-grid';
|
||||
this.appendChild(this._fileInput);
|
||||
this.appendChild(this._grid);
|
||||
this.uploadsDone = 0;
|
||||
this.uploadsStarted = 0;
|
||||
}
|
||||
|
||||
reset(){
|
||||
this.uploadResponses = [];
|
||||
this.uploadsDone = 0;
|
||||
this.uploadsStarted = 0;
|
||||
this._grid.innerHTML = '';
|
||||
}
|
||||
|
||||
get isBusy(){
|
||||
return this.uploadsDone != this.uploadsStarted;
|
||||
}
|
||||
|
||||
handleFiles(files) {
|
||||
this.reset()
|
||||
this.uploadsDone = 0;
|
||||
this.uploadsStarted = files.length;
|
||||
|
||||
[...files].forEach(file => this.createTile(file));
|
||||
}
|
||||
connectedCallback() {
|
||||
this.channelUid = this.getAttribute('channel');
|
||||
this.render();
|
||||
this._fileInput.addEventListener('change', e => this.handleFiles(e.target.files));
|
||||
}
|
||||
|
||||
createTile(file) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'fug-tile';
|
||||
// Icon/Thumbnail
|
||||
let icon;
|
||||
if (file.type.startsWith('image/')) {
|
||||
icon = document.createElement('img');
|
||||
icon.className = 'fug-icon';
|
||||
icon.src = URL.createObjectURL(file);
|
||||
icon.onload = () => URL.revokeObjectURL(icon.src);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
icon = document.createElement('video');
|
||||
icon.className = 'fug-icon';
|
||||
icon.src = URL.createObjectURL(file);
|
||||
icon.muted = true;
|
||||
icon.playsInline = true;
|
||||
icon.controls = false;
|
||||
icon.preload = 'metadata';
|
||||
icon.onloadeddata = () => {
|
||||
icon.currentTime = 0.5;
|
||||
URL.revokeObjectURL(icon.src);
|
||||
};
|
||||
} else {
|
||||
icon = document.createElement('img');
|
||||
icon.className = 'fug-icon';
|
||||
icon.src = FUG_ICONS.file;
|
||||
}
|
||||
// Filename
|
||||
const name = document.createElement('div');
|
||||
name.className = 'fug-filename';
|
||||
name.textContent = file.name;
|
||||
// Progressbar
|
||||
const progressbar = document.createElement('div');
|
||||
progressbar.className = 'fug-progressbar';
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'fug-progress';
|
||||
progressbar.appendChild(progress);
|
||||
|
||||
// Tile composition
|
||||
tile.appendChild(icon);
|
||||
tile.appendChild(name);
|
||||
tile.appendChild(progressbar);
|
||||
this._grid.appendChild(tile);
|
||||
|
||||
// Start upload
|
||||
this.startUpload(file, tile, progress);
|
||||
|
||||
}
|
||||
|
||||
startUpload(file, tile, progress) {
|
||||
|
||||
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
|
||||
console.info("File uploading",file)
|
||||
|
||||
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
let sent = 0;
|
||||
|
||||
ws.onopen = async () => {
|
||||
ws.send(JSON.stringify({type: 'start', filename: file.name}));
|
||||
const chunkSize = 64*1024;
|
||||
while (sent < file.size) {
|
||||
const chunk = file.slice(sent, sent + chunkSize);
|
||||
ws.send(await chunk.arrayBuffer());
|
||||
sent += chunkSize;
|
||||
}
|
||||
ws.send(JSON.stringify({type: 'end', filename: file.name}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'progress') {
|
||||
const pct = Math.min(100, Math.round(100 * data.bytes / file.size));
|
||||
progress.style.width = pct + '%';
|
||||
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
|
||||
} else if (data.type === 'done') {
|
||||
this.uploadsDone += 1;
|
||||
this.publish('file-uploaded', {file: file, tile: tile, progress: progress});
|
||||
progress.style.width = '100%';
|
||||
tile.classList.add('fug-done');
|
||||
ws.close();
|
||||
this.uploadResponses.push({file:file, remoteFile:data.file})
|
||||
if(this.uploadsDone == this.uploadsStarted){
|
||||
this.publish('file-uploads-done', this.uploadResponses);
|
||||
console.info({"X":this.uploadResponses})
|
||||
this.reset()
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-upload-grid', FileUploadGrid);
|
||||
|
||||
export { FileUploadGrid };
|
Before ![]() (image error) Size: 1.3 MiB |
Before ![]() (image error) Size: 1.2 MiB |
Before ![]() (image error) Size: 14 KiB |
Before ![]() (image error) Size: 17 KiB |
Before ![]() (image error) Size: 1.0 KiB |
Before ![]() (image error) Size: 25 KiB |
Before ![]() (image error) Size: 40 KiB |
Before ![]() (image error) Size: 1.8 KiB |
Before ![]() (image error) Size: 79 KiB |
Before ![]() (image error) Size: 3.2 KiB |
Before ![]() (image error) Size: 132 KiB |
Before ![]() (image error) Size: 117 KiB |
Before ![]() (image error) Size: 5.0 KiB |
Before ![]() (image error) Size: 5.9 KiB |
Before ![]() (image error) Size: 177 KiB |
Before ![]() (image error) Size: 9.0 KiB |
Before ![]() (image error) Size: 1.3 MiB |
@ -1,58 +1,30 @@
|
||||
{
|
||||
"id": "snek",
|
||||
"name": "Snek",
|
||||
"short_name": "Snek",
|
||||
"description": "Snek Software Development Community",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/web.html",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"dir": "ltr",
|
||||
"lang": "en-US",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/image/snek_logo_32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_64x64.png",
|
||||
"type": "image/png",
|
||||
"sizes": "64x64"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_128x128.png",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_144x144.png",
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_256x256.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek_logo_1024x1024.png",
|
||||
"type": "image/png",
|
||||
"sizes": "1024x1024"
|
||||
}
|
||||
],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
"id": "snek",
|
||||
"name": "Snek",
|
||||
"description": "Danger noodle",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false,
|
||||
"screenshots": [],
|
||||
"dir": "ltr",
|
||||
"lang": "en-US",
|
||||
"launch_path": "/web.html",
|
||||
"short_name": "Snek",
|
||||
"start_url": "/web.html",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/image/snek192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -5,306 +5,103 @@
|
||||
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
|
||||
|
||||
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
||||
import { app } from "./app.js";
|
||||
|
||||
const LONG_TIME = 1000 * 60 * 20;
|
||||
|
||||
export class ReplyEvent extends Event {
|
||||
constructor(messageTextTarget) {
|
||||
super('reply', { bubbles: true, composed: true });
|
||||
this.messageTextTarget = messageTextTarget;
|
||||
|
||||
// Clone and sanitize message node to text-only reply
|
||||
const newMessage = messageTextTarget.cloneNode(true);
|
||||
newMessage.style.maxHeight = "0";
|
||||
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
|
||||
|
||||
// Remove all .embed-url-link
|
||||
newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
|
||||
|
||||
// Replace <picture> with their <img>
|
||||
newMessage.querySelectorAll('picture').forEach(picture => {
|
||||
const img = picture.querySelector('img');
|
||||
if (img) picture.replaceWith(img);
|
||||
});
|
||||
|
||||
// Replace <img> with just their src
|
||||
newMessage.querySelectorAll('img').forEach(img => {
|
||||
const src = img.src || img.currentSrc;
|
||||
img.replaceWith(document.createTextNode(src));
|
||||
});
|
||||
|
||||
// Replace <iframe> with their src
|
||||
newMessage.querySelectorAll('iframe').forEach(iframe => {
|
||||
const src = iframe.src || iframe.currentSrc;
|
||||
iframe.replaceWith(document.createTextNode(src));
|
||||
});
|
||||
|
||||
// Replace <a> with href or markdown
|
||||
newMessage.querySelectorAll('a').forEach(a => {
|
||||
const href = a.getAttribute('href');
|
||||
const text = a.innerText || a.textContent;
|
||||
if (text === href || text === '') {
|
||||
a.replaceWith(document.createTextNode(href));
|
||||
} else {
|
||||
a.replaceWith(document.createTextNode(`[${text}](${href})`));
|
||||
}
|
||||
});
|
||||
|
||||
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
|
||||
newMessage.remove();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageElement extends HTMLElement {
|
||||
updateUI() {
|
||||
if (this._originalChildren === undefined) {
|
||||
const { color, user_nick, created_at, user_uid } = this.dataset;
|
||||
this.classList.add('message');
|
||||
this.style.maxWidth = '100%';
|
||||
this._originalChildren = Array.from(this.children);
|
||||
|
||||
this.innerHTML = `
|
||||
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
|
||||
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
|
||||
</a>
|
||||
<div class="message-content">
|
||||
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
|
||||
<div class="text"></div>
|
||||
<div class="time no-select" data-created_at="${created_at || ''}">
|
||||
<span></span>
|
||||
<a href="#reply">reply</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.messageDiv = this.querySelector('.text');
|
||||
if (this._originalChildren && this._originalChildren.length > 0) {
|
||||
this._originalChildren.forEach(child => {
|
||||
this.messageDiv.appendChild(child);
|
||||
});
|
||||
}
|
||||
|
||||
this.timeDiv = this.querySelector('.time span');
|
||||
this.replyDiv = this.querySelector('.time a');
|
||||
|
||||
this.replyDiv.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new ReplyEvent(this.messageDiv));
|
||||
});
|
||||
}
|
||||
|
||||
// Sibling logic for user switches and long time gaps
|
||||
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
|
||||
this.siblingGenerated = this.nextElementSibling;
|
||||
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
|
||||
this.classList.add('switch-user');
|
||||
} else {
|
||||
this.classList.remove('switch-user');
|
||||
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
|
||||
const currentTime = new Date(this.dataset.created_at);
|
||||
|
||||
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
|
||||
this.classList.add('long-time');
|
||||
} else {
|
||||
this.classList.remove('long-time');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.timeDiv.innerText = app.timeDescription(this.dataset.created_at);
|
||||
}
|
||||
|
||||
updateMessage(...messages) {
|
||||
if (this._originalChildren) {
|
||||
this.messageDiv.replaceChildren(...messages);
|
||||
this._originalChildren = messages;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
disconnectedCallback() {}
|
||||
connectedMoveCallback() {}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
this.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
import { app } from "../app.js";
|
||||
class MessageList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.messageMap = new Map();
|
||||
this.visibleSet = new Set();
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
this.visibleSet.add(entry.target);
|
||||
if (entry.target instanceof MessageElement) {
|
||||
entry.target.updateUI();
|
||||
}
|
||||
} else {
|
||||
this.visibleSet.delete(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: this,
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
// End-of-messages marker
|
||||
this.endOfMessages = document.createElement('div');
|
||||
this.endOfMessages.classList.add('message-list-bottom');
|
||||
this.prepend(this.endOfMessages);
|
||||
|
||||
// Observe existing children and index by uid
|
||||
for (const c of this.children) {
|
||||
this._observer.observe(c);
|
||||
if (c instanceof MessageElement) {
|
||||
this.messageMap.set(c.dataset.uid, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up socket events
|
||||
app.ws.addEventListener("update_message_text", (data) => {
|
||||
if (this.messageMap.has(data.uid)) {
|
||||
this.upsertMessage(data);
|
||||
}
|
||||
this.updateMessageText(data.uid, data);
|
||||
});
|
||||
app.ws.addEventListener("set_typing", (data) => {
|
||||
this.triggerGlow(data.user_uid, data.color);
|
||||
this.triggerGlow(data.user_uid,data.color);
|
||||
});
|
||||
|
||||
this.scrollToBottom(true);
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', (e) => {
|
||||
if (
|
||||
e.target.tagName !== 'IMG' ||
|
||||
e.target.classList.contains('avatar-img')
|
||||
) return;
|
||||
connectedCallback() {
|
||||
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 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));
|
||||
})
|
||||
|
||||
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;cursor:pointer;';
|
||||
|
||||
const urlObj = new URL(img.currentSrc || img.src, window.location.origin);
|
||||
urlObj.searchParams.delete('width');
|
||||
urlObj.searchParams.delete('height');
|
||||
|
||||
const fullImg = document.createElement('img');
|
||||
fullImg.src = urlObj.toString();
|
||||
fullImg.alt = img.alt || '';
|
||||
fullImg.style.maxWidth = '90%';
|
||||
fullImg.style.maxHeight = '90%';
|
||||
fullImg.style.boxShadow = '0 0 32px #000';
|
||||
fullImg.style.borderRadius = '8px';
|
||||
fullImg.style.background = '#222';
|
||||
fullImg.style.objectFit = 'contain';
|
||||
fullImg.loading = 'lazy';
|
||||
|
||||
overlay.appendChild(fullImg);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
// ESC to close
|
||||
const escListener = (evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
document.removeEventListener('keydown', escListener);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escListener);
|
||||
});
|
||||
}
|
||||
|
||||
isElementVisible(element) {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
isScrolledToBottom() {
|
||||
return this.visibleSet.has(this.endOfMessages);
|
||||
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
||||
}
|
||||
scrollToBottom(force) {
|
||||
this.scrollTop = this.scrollHeight;
|
||||
|
||||
scrollToBottom(force = false, behavior = 'instant') {
|
||||
if (force || !this.isScrolledToBottom()) {
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||
setTimeout(() => {
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
}, 200);
|
||||
}
|
||||
|
||||
this.scrollTop = this.scrollHeight;
|
||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||
},200)
|
||||
}
|
||||
updateMessageText(uid, message) {
|
||||
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
||||
|
||||
triggerGlow(uid, color) {
|
||||
if (!uid || !color) return;
|
||||
app.starField.glowColor(color);
|
||||
let lastElement = null;
|
||||
this.querySelectorAll('.avatar').forEach((el) => {
|
||||
const anchor = el.closest('a');
|
||||
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
|
||||
if (!messageDiv) {
|
||||
return;
|
||||
}
|
||||
const scrollToBottom = this.isScrolledToBottom();
|
||||
const receivedHtml = document.createElement("div");
|
||||
receivedHtml.innerHTML = message.html;
|
||||
const html = receivedHtml.querySelector(".text").innerHTML;
|
||||
const textElement = messageDiv.querySelector(".text");
|
||||
textElement.innerHTML = html;
|
||||
textElement.style.display = message.text == "" ? "none" : "block";
|
||||
if(scrollToBottom)
|
||||
this.scrollToBottom(true)
|
||||
}
|
||||
triggerGlow(uid,color) {
|
||||
app.starField.glowColor(color)
|
||||
let lastElement = null;
|
||||
this.querySelectorAll(".avatar").forEach((el) => {
|
||||
const div = el.closest("a");
|
||||
if (el.href.indexOf(uid) != -1) {
|
||||
lastElement = el;
|
||||
}
|
||||
});
|
||||
if (lastElement) {
|
||||
lastElement.classList.add('glow');
|
||||
lastElement.classList.add("glow");
|
||||
setTimeout(() => {
|
||||
lastElement.classList.remove('glow');
|
||||
lastElement.classList.remove("glow");
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
updateTimes() {
|
||||
this.visibleSet.forEach((messageElement) => {
|
||||
if (messageElement instanceof MessageElement) {
|
||||
messageElement.updateUI();
|
||||
}
|
||||
});
|
||||
set data(items) {
|
||||
this.items = items;
|
||||
this.render();
|
||||
}
|
||||
render() {
|
||||
this.innerHTML = "";
|
||||
|
||||
upsertMessage(data) {
|
||||
let message = this.messageMap.get(data.uid);
|
||||
if (message && (data.is_final || !data.message)) {
|
||||
message.parentElement?.removeChild(message);
|
||||
// TO force insert
|
||||
message = null;
|
||||
|
||||
}
|
||||
if (!data.message) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = data.html;
|
||||
|
||||
if (message) {
|
||||
// If the old element is already custom, only update its message children
|
||||
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
|
||||
} else {
|
||||
// If not, insert the new one and observe
|
||||
message = wrapper.firstElementChild;
|
||||
this.messageMap.set(data.uid, message);
|
||||
this._observer.observe(message);
|
||||
this.endOfMessages.after(message);
|
||||
}
|
||||
|
||||
const scrolledToBottom = this.isScrolledToBottom();
|
||||
|
||||
if (scrolledToBottom) this.scrollToBottom(true);
|
||||
//this.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-message", MessageElement);
|
||||
customElements.define("message-list", MessageList);
|
||||
|
||||
|
@ -1,18 +0,0 @@
|
||||
|
||||
/* CSS version */
|
||||
.njet-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px;
|
||||
min-width: 33px;
|
||||
}
|
||||
.njet-window {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px;
|
||||
min-width: 33px;
|
||||
}
|
@ -1,585 +0,0 @@
|
||||
class RestClient {
|
||||
constructor({ baseURL = '', headers = {} } = {}) {
|
||||
this.baseURL = baseURL;
|
||||
this.headers = { ...headers };
|
||||
|
||||
// Interceptor containers
|
||||
this.interceptors = {
|
||||
request: {
|
||||
handlers: [],
|
||||
use: (fn) => {
|
||||
this.interceptors.request.handlers.push(fn);
|
||||
}
|
||||
},
|
||||
response: {
|
||||
handlers: [],
|
||||
use: (successFn, errorFn) => {
|
||||
this.interceptors.response.handlers.push({ success: successFn, error: errorFn });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Core request method
|
||||
request(config) {
|
||||
// Merge defaults
|
||||
const cfg = {
|
||||
method: 'GET',
|
||||
url: '',
|
||||
data: null,
|
||||
headers: {},
|
||||
...config
|
||||
};
|
||||
cfg.headers = { ...this.headers, ...cfg.headers };
|
||||
|
||||
// Apply request interceptors
|
||||
let chain = Promise.resolve(cfg);
|
||||
this.interceptors.request.handlers.forEach((fn) => {
|
||||
chain = chain.then(fn);
|
||||
});
|
||||
|
||||
// Perform fetch
|
||||
chain = chain.then((c) => {
|
||||
const url = this.baseURL + c.url;
|
||||
const options = { method: c.method, headers: c.headers };
|
||||
if (c.data != null) {
|
||||
options.body = JSON.stringify(c.data);
|
||||
if (!options.headers['Content-Type']) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
return fetch(url, options).then(async (response) => {
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
data = text;
|
||||
}
|
||||
const result = {
|
||||
data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: RestClient._parseHeaders(response.headers),
|
||||
config: c,
|
||||
request: response
|
||||
};
|
||||
if (!response.ok) {
|
||||
return Promise.reject(result);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
|
||||
// Apply response interceptors
|
||||
this.interceptors.response.handlers.forEach(({ success, error }) => {
|
||||
chain = chain.then(success, error);
|
||||
});
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
// Helper methods for HTTP verbs
|
||||
get(url, config) {
|
||||
return this.request({ ...config, method: 'GET', url });
|
||||
}
|
||||
delete(url, config) {
|
||||
return this.request({ ...config, method: 'DELETE', url });
|
||||
}
|
||||
head(url, config) {
|
||||
return this.request({ ...config, method: 'HEAD', url });
|
||||
}
|
||||
options(url, config) {
|
||||
return this.request({ ...config, method: 'OPTIONS', url });
|
||||
}
|
||||
post(url, data, config) {
|
||||
return this.request({ ...config, method: 'POST', url, data });
|
||||
}
|
||||
put(url, data, config) {
|
||||
return this.request({ ...config, method: 'PUT', url, data });
|
||||
}
|
||||
patch(url, data, config) {
|
||||
return this.request({ ...config, method: 'PATCH', url, data });
|
||||
}
|
||||
|
||||
// Utility to parse Fetch headers into an object
|
||||
static _parseHeaders(headers) {
|
||||
const result = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Njet extends HTMLElement {
|
||||
static _root = null
|
||||
static showDialog = null
|
||||
|
||||
get isRoot() {
|
||||
return Njet._root === this
|
||||
}
|
||||
|
||||
get root() {
|
||||
return Njet._root
|
||||
}
|
||||
|
||||
get rest() {
|
||||
return Njet._root._rest
|
||||
}
|
||||
attach(element) {
|
||||
this._attachedTo = element
|
||||
this._attachedTo.addEventListener("resize", () => {
|
||||
this.updatePosition()
|
||||
})
|
||||
}
|
||||
updatePosition(){
|
||||
if(this._attachedTo)
|
||||
{
|
||||
this.style.width = `${this._attachedTo.offsetWidth}`
|
||||
this.style.height = `${this._attachedTo.offsetHeight}`
|
||||
this.style.left = `${this._attachedTo.offsetLeft}`
|
||||
this.style.top = `${this._attachedTo.offsetTop}`
|
||||
this.style.position = 'fixed'
|
||||
}
|
||||
}
|
||||
_subscriptions = {}
|
||||
_elements = []
|
||||
_rest = null
|
||||
_attachedTo = null
|
||||
match(args) {
|
||||
return Object.entries(args).every(([key, value]) => this[key] === value);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.dataset[key] = value
|
||||
}
|
||||
|
||||
get(key, defaultValue) {
|
||||
if (this.dataset[key]) {
|
||||
return this.dataset[key]
|
||||
}
|
||||
if (defaultValue === undefined) {
|
||||
return
|
||||
}
|
||||
this.dataset[key] = defaultValue
|
||||
return this.dataset[key]
|
||||
}
|
||||
|
||||
showDialog(args){
|
||||
|
||||
// const dialog = this.createComponent('njet-dialog', args)
|
||||
// dialog.show()
|
||||
// return dialog()
|
||||
}
|
||||
|
||||
find(args) {
|
||||
for (let element of this.root._elements) {
|
||||
if (element.match(args)) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
findAll(args) {
|
||||
return this.root._elements.filter(element => element.match(args))
|
||||
}
|
||||
|
||||
subscribe(event, callback) {
|
||||
if (!this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event] = []
|
||||
}
|
||||
this.root._subscriptions[event].push(callback)
|
||||
}
|
||||
|
||||
publish(event, data) {
|
||||
if (this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event].forEach(callback => callback(data))
|
||||
}
|
||||
}
|
||||
|
||||
static registerComponent(name, component) {
|
||||
customElements.define(name, component);
|
||||
}
|
||||
|
||||
constructor(config) {
|
||||
super();
|
||||
// Store the config for use in render and other methods
|
||||
this.config = config || {};
|
||||
|
||||
if (!Njet._root) {
|
||||
Njet._root = this
|
||||
Njet._rest = new RestClient({ baseURL: '/' || null })
|
||||
}
|
||||
this.root._elements.push(this)
|
||||
this.classList.add('njet');
|
||||
|
||||
// Initialize properties from config before rendering
|
||||
this.initProps(this.config);
|
||||
|
||||
// Call render after properties are initialized
|
||||
this.render.call(this);
|
||||
|
||||
// Call construct if defined
|
||||
if (typeof this.config.construct === 'function') {
|
||||
this.config.construct.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
initProps(config) {
|
||||
const props = Object.keys(config)
|
||||
props.forEach(prop => {
|
||||
// Skip special properties that are handled separately
|
||||
if (['construct', 'items', 'classes'].includes(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a setter for this property
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), prop);
|
||||
if (descriptor && descriptor.set) {
|
||||
// Use the setter
|
||||
this[prop] = config[prop];
|
||||
} else if (prop in this) {
|
||||
// Property exists, set it directly
|
||||
this[prop] = config[prop];
|
||||
} else {
|
||||
// Set as attribute for unknown properties
|
||||
this.setAttribute(prop, config[prop]);
|
||||
}
|
||||
});
|
||||
|
||||
if (config.classes) {
|
||||
this.classList.add(...config.classes);
|
||||
}
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
const duplicatedConfig = { ...this.config };
|
||||
if (duplicatedConfig.items) {
|
||||
duplicatedConfig.items = duplicatedConfig.items.map(item => {
|
||||
return typeof item.duplicate === 'function' ? item.duplicate() : item;
|
||||
});
|
||||
}
|
||||
return new this.constructor(duplicatedConfig);
|
||||
}
|
||||
|
||||
set width(val) {
|
||||
this.style.width = typeof val === 'number' ? `${val}px` : val;
|
||||
}
|
||||
|
||||
get width() { return this.style.width; }
|
||||
|
||||
set height(val) {
|
||||
this.style.height = typeof val === 'number' ? `${val}px` : val;
|
||||
}
|
||||
|
||||
get height() { return this.style.height; }
|
||||
|
||||
set left(val) {
|
||||
this.style.position = 'absolute';
|
||||
this.style.left = typeof val === 'number' ? `${val}px` : val;
|
||||
}
|
||||
|
||||
get left() { return this.style.left; }
|
||||
|
||||
set top(val) {
|
||||
this.style.position = 'absolute';
|
||||
this.style.top = typeof val === 'number' ? `${val}px` : val;
|
||||
}
|
||||
|
||||
get top() { return this.style.top; }
|
||||
|
||||
set opacity(val) { this.style.opacity = val; }
|
||||
get opacity() { return this.style.opacity; }
|
||||
|
||||
set disabled(val) { this.toggleAttribute('disabled', !!val); }
|
||||
get disabled() { return this.hasAttribute('disabled'); }
|
||||
|
||||
set visible(val) { this.style.display = val ? '' : 'none'; }
|
||||
get visible() { return this.style.display !== 'none'; }
|
||||
|
||||
render() {}
|
||||
}
|
||||
Njet.registerComponent('njet-root', Njet);
|
||||
|
||||
class Component extends Njet {}
|
||||
|
||||
Njet.registerComponent('njet-component', Component);
|
||||
|
||||
class NjetPanel extends Component {
|
||||
render() {
|
||||
this.innerHTML = '';
|
||||
const { title, items = [] } = this.config;
|
||||
this.style.border = '1px solid #ccc';
|
||||
this.style.padding = '10px';
|
||||
if (title) {
|
||||
const header = document.createElement('h3');
|
||||
header.textContent = title;
|
||||
this.appendChild(header);
|
||||
}
|
||||
items.forEach(item => this.appendChild(item));
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-panel', NjetPanel);
|
||||
|
||||
class NjetButton extends Component {
|
||||
render() {
|
||||
this.classList.add('njet-button');
|
||||
this.innerHTML = '';
|
||||
const button = document.createElement('button');
|
||||
button.textContent = this.config.text || 'Button';
|
||||
if (typeof this.config.handler === 'function') {
|
||||
button.addEventListener('click', (event) => this.config.handler.call(this));
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
button.disabled = this.disabled;
|
||||
});
|
||||
observer.observe(this, { attributes: true, attributeFilter: ['disabled'] });
|
||||
button.disabled = this.disabled;
|
||||
this.appendChild(button);
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-button', NjetButton);
|
||||
|
||||
class NjetDialog extends Component {
|
||||
render() {
|
||||
this.innerHTML = '';
|
||||
const { title, content, primaryButton, secondaryButton } = this.config;
|
||||
this.classList.add('njet-dialog');
|
||||
if (title) {
|
||||
const header = document.createElement('h2');
|
||||
header.textContent = title;
|
||||
this.appendChild(header);
|
||||
}
|
||||
if (content) {
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = content;
|
||||
this.appendChild(body);
|
||||
}
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.marginTop = '20px';
|
||||
buttonContainer.style.display = 'flex';
|
||||
buttonContainer.style.justifyContent = 'flex-end';
|
||||
buttonContainer.style.gap = '10px';
|
||||
if (secondaryButton) {
|
||||
const secondary = new NjetButton(secondaryButton);
|
||||
buttonContainer.appendChild(secondary);
|
||||
}
|
||||
if (primaryButton) {
|
||||
const primary = new NjetButton(primaryButton);
|
||||
buttonContainer.appendChild(primary);
|
||||
}
|
||||
this.appendChild(buttonContainer);
|
||||
}
|
||||
|
||||
show(){
|
||||
document.body.appendChild(this)
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-dialog', NjetDialog);
|
||||
|
||||
class NjetWindow extends Component {
|
||||
render() {
|
||||
this.innerHTML = '';
|
||||
const { title, content, primaryButton, secondaryButton } = this.config;
|
||||
this.classList.add('njet-window');
|
||||
|
||||
if (title) {
|
||||
const header = document.createElement('h2');
|
||||
header.textContent = title;
|
||||
this.appendChild(header);
|
||||
}
|
||||
if (this.config.items) {
|
||||
this.config.items.forEach(item => this.appendChild(item));
|
||||
}
|
||||
}
|
||||
|
||||
show(){
|
||||
document.body.appendChild(this)
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-window', NjetWindow);
|
||||
|
||||
|
||||
|
||||
|
||||
class NjetGrid extends Component {
|
||||
render() {
|
||||
this.classList.add('njet-grid');
|
||||
this.innerHTML = '';
|
||||
const table = document.createElement('table');
|
||||
table.style.width = '100%';
|
||||
table.style.borderCollapse = 'collapse';
|
||||
const data = this.config.data || [];
|
||||
data.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
Object.values(row).forEach(cell => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = cell;
|
||||
td.style.border = '1px solid #ddd';
|
||||
td.style.padding = '4px';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
table.appendChild(tr);
|
||||
});
|
||||
this.appendChild(table);
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-grid', NjetGrid);
|
||||
|
||||
/* Example usage:
|
||||
const button = new NjetButton({
|
||||
classes: ['my-button'],
|
||||
text: 'Shared',
|
||||
tag: 'shared',
|
||||
width: 120,
|
||||
height: 30,
|
||||
handler() {
|
||||
this.root.findAll({ tag: 'shared' }).forEach(e => {
|
||||
e.disabled = !e.disabled;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const button2 = new NjetButton({
|
||||
classes: ['my-button'],
|
||||
text: 'Single',
|
||||
iValue: 0,
|
||||
width: 120,
|
||||
height: 30,
|
||||
handler() {
|
||||
this.iValue++;
|
||||
const panel = this.closest('njet-panel');
|
||||
if (panel) {
|
||||
const h3 = panel.querySelector('h3');
|
||||
if (h3) h3.textContent = `Click ${this.iValue}`;
|
||||
}
|
||||
this.publish("btn2Click", `Click ${this.iValue}`);
|
||||
}
|
||||
});
|
||||
|
||||
const grid = new NjetGrid({
|
||||
data: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' }
|
||||
],
|
||||
width: '100%',
|
||||
visible: true
|
||||
});
|
||||
|
||||
const panel = new NjetPanel({
|
||||
title: 'My Panel',
|
||||
items: [button, grid, button2],
|
||||
left: 50,
|
||||
top: 50,
|
||||
construct: function () {
|
||||
this.subscribe('btn2Click', (data) => {
|
||||
this._title = data
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const panelClone = panel.duplicate();
|
||||
const panell = panelClone.duplicate();
|
||||
panell.left = 120;
|
||||
panell.width = 300;
|
||||
panelClone.appendChild(panell);
|
||||
panelClone.left = 300;
|
||||
panelClone.top = 50;
|
||||
document.body.appendChild(panelClone);
|
||||
|
||||
const dialog = new NjetDialog({
|
||||
title: 'Confirm Action',
|
||||
content: 'Are you sure you want to continue?',
|
||||
primaryButton: {
|
||||
text: 'Yes',
|
||||
handler: function () {
|
||||
alert('Confirmed');
|
||||
this.closest('njet-dialog').remove();
|
||||
}
|
||||
},
|
||||
secondaryButton: {
|
||||
text: 'Cancel',
|
||||
handler: function () {
|
||||
this.closest('njet-dialog').remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
*/
|
||||
|
||||
class NjetComponent extends Component {}
|
||||
const njet = Njet
|
||||
njet.showDialog = function(args){
|
||||
const dialog = new NjetDialog(args)
|
||||
dialog.show()
|
||||
return dialog
|
||||
}
|
||||
|
||||
class EventBus extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.eventMap = new Map();
|
||||
}
|
||||
|
||||
subscribe(eventName, callback) {
|
||||
this.addEventListener(eventName, callback);
|
||||
|
||||
if (!this.eventMap.has(eventName)) {
|
||||
this.eventMap.set(eventName, []);
|
||||
}
|
||||
this.eventMap.get(eventName).push(callback);
|
||||
}
|
||||
|
||||
publish(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
unsubscribe(eventName, callback) {
|
||||
this.removeEventListener(eventName, callback);
|
||||
|
||||
const subscribers = this.eventMap.get(eventName);
|
||||
if (subscribers) {
|
||||
const index = subscribers.indexOf(callback);
|
||||
if (index > -1) subscribers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventBus = new EventBus()
|
||||
|
||||
|
||||
njet.showWindow = function(args) {
|
||||
const w = new NjetWindow(args)
|
||||
w.show()
|
||||
return w
|
||||
}
|
||||
njet.publish = function(event, data) {
|
||||
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event].forEach(callback => callback(data))
|
||||
}
|
||||
}
|
||||
njet.subscribe = function(event, callback) {
|
||||
if (!this.root) return;
|
||||
if (!this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event] = []
|
||||
}
|
||||
this.root._subscriptions[event].push(callback)
|
||||
}
|
||||
|
||||
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus };
|
@ -1,57 +1,34 @@
|
||||
export const registerServiceWorker = async (silent = false) => {
|
||||
try {
|
||||
const serviceWorkerRegistration = await navigator.serviceWorker
|
||||
.register("/service-worker.js")
|
||||
|
||||
await serviceWorkerRegistration.update()
|
||||
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
const keyResponse = await fetch('/push.json')
|
||||
const keyData = await keyResponse.json()
|
||||
|
||||
const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0))
|
||||
|
||||
const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true, applicationServerKey: publicKey,
|
||||
})
|
||||
|
||||
const subscriptionObject = {
|
||||
...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings,
|
||||
};
|
||||
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");
|
||||
}
|
||||
});
|
||||
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.
|
||||
};
|
||||
registerServiceWorker(true).catch(console.error);
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/service-worker.js")
|
||||
.then((serviceWorkerRegistration) => {
|
||||
serviceWorkerRegistration.pushManager.subscribe().then(
|
||||
(pushSubscription) => {
|
||||
const subscriptionObject = {
|
||||
endpoint: pushSubscription.endpoint,
|
||||
keys: {
|
||||
p256dh: pushSubscription.getKey("p256dh"),
|
||||
auth: pushSubscription.getKey("auth"),
|
||||
},
|
||||
encoding: PushManager.supportedContentEncodings,
|
||||
/* other app-specific data, such as user identity */
|
||||
};
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
@ -1,80 +1,65 @@
|
||||
function isClientOpen(url) {
|
||||
return clients.matchAll().then((matchedClients) => {
|
||||
return matchedClients.some((matchedClient) => {
|
||||
return matchedClient.url === url && "focus" in matchedClient;
|
||||
});
|
||||
});
|
||||
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 installing...");
|
||||
event.waitUntil(
|
||||
caches.open("snek-cache").then((cache) => {
|
||||
return cache.addAll([]);
|
||||
})
|
||||
);
|
||||
})
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.registration?.navigationPreload.enable());
|
||||
console.log("Service worker installed");
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!(self.Notification && self.Notification.permission === "granted")) {
|
||||
return;
|
||||
}
|
||||
console.log("Received a push message", event);
|
||||
|
||||
self.addEventListener("push", async (event) => {
|
||||
if (!self.Notification || self.Notification.permission !== "granted") {
|
||||
console.log("Notification permission not granted");
|
||||
return;
|
||||
}
|
||||
const data = event.data?.json() ?? {};
|
||||
const title = data.title || "Something Has Happened";
|
||||
const message =
|
||||
data.message || "Here's something you might want to check out.";
|
||||
const icon = "images/new-notification.png";
|
||||
|
||||
const data = event.data?.json() ?? {};
|
||||
console.log("Received a push message", event, data);
|
||||
event.waitUntil(self.registration.showNotification(title, {
|
||||
body: message,
|
||||
tag: "simple-push-demo-notification",
|
||||
icon,
|
||||
}));
|
||||
|
||||
if(await isClientOpen(data.url || data.link || `/web.html`)){
|
||||
console.log("Client already open, not showing notification.");
|
||||
return;
|
||||
}
|
||||
|
||||
const title = data.title || "Something Has Happened";
|
||||
const message =
|
||||
data.message || "Here's something you might want to check out.";
|
||||
const icon = data.icon || "/image/snek512.png";
|
||||
|
||||
const notificationSettings = data.notificationSettings || {};
|
||||
|
||||
console.log("Showing message", title, message, icon);
|
||||
|
||||
const reg = self.registration.showNotification(title, {
|
||||
body: message,
|
||||
tag: "message-received",
|
||||
icon,
|
||||
badge: icon,
|
||||
...notificationSettings,
|
||||
data,
|
||||
}).then(e => console.log("Showing notification", e)).catch(console.error);
|
||||
|
||||
event.waitUntil(reg);
|
||||
});
|
||||
|
||||
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
console.log("Notification click Received.", event);
|
||||
event.notification.close();
|
||||
event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclose", (event) => {
|
||||
console.log("Notification closed", event);
|
||||
})
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
// console.log("Found response in cache: ", response);
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
})
|
||||
event.waitUntil(clients.openWindow(
|
||||
"https://snek.molodetz.nl",));
|
||||
});*/
|
||||
|
@ -142,9 +142,10 @@ export class Socket extends EventHandler {
|
||||
method,
|
||||
args,
|
||||
};
|
||||
const me = this;
|
||||
return new Promise((resolve) => {
|
||||
this.addEventListener(call.callId, (data) => resolve(data), { once: true});
|
||||
this.sendJson(call);
|
||||
me.addEventListener(call.callId, (data) => resolve(data));
|
||||
me.sendJson(call);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,181 +0,0 @@
|
||||
class STTButton extends HTMLElement {
|
||||
static get observedAttributes() { return ['target']; }
|
||||
|
||||
simulateTypingWithEvents(element, text, delay = 100) {
|
||||
let resolver = null;
|
||||
const promise = new Promise((resolve) => resolver = resolve);
|
||||
let index = 0;
|
||||
|
||||
const triggerEvent = (type, key) => {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key: key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (index < text.length) {
|
||||
const char = text.charAt(index);
|
||||
|
||||
triggerEvent('keydown', char);
|
||||
|
||||
if (element.isContentEditable) {
|
||||
document.execCommand('insertText', false, char);
|
||||
} else {
|
||||
element.value += char;
|
||||
}
|
||||
|
||||
triggerEvent('keypress', char);
|
||||
triggerEvent('keyup', char);
|
||||
index++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
resolver();
|
||||
}
|
||||
}, delay);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('part', 'button');
|
||||
btn.setAttribute('aria-label', 'Start voice dictation');
|
||||
btn.innerHTML = '🎤';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host { display:inline-block; }
|
||||
button { all:unset; cursor:pointer; }
|
||||
button:hover { background:#e8e8e8; }
|
||||
:host([listening]) button {
|
||||
background:#d32f2f; color:#fff;
|
||||
animation:pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%,100% { transform:scale(1); box-shadow:0 0 0 0 rgba(211,47,47,.6);}
|
||||
50% { transform:scale(1.12);box-shadow:0 0 0 12px rgba(211,47,47,0);}
|
||||
}
|
||||
`;
|
||||
this.shadowRoot.append(style, btn);
|
||||
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) {
|
||||
console.warn('Web Speech API not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.recog = new SR();
|
||||
this.recog.lang = 'en-US';
|
||||
this.recog.continuous = true;
|
||||
this.recog.interimResults = true;
|
||||
|
||||
let interim = '';
|
||||
let committed = '';
|
||||
let previousInterim = '';
|
||||
|
||||
this.recog.onresult = (e) => {
|
||||
interim = '';
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const res = e.results[i];
|
||||
const alt = res[0];
|
||||
const txt = alt.transcript.trim();
|
||||
|
||||
if (res.isFinal) {
|
||||
const sentence = txt.charAt(0).toUpperCase() + txt.slice(1);
|
||||
let punctuated = /[.!?]$/.test(sentence) ? sentence : sentence + '.';
|
||||
|
||||
committed += punctuated + ' ';
|
||||
if (this.targetEl) {
|
||||
this.targetEl.focus();
|
||||
punctuated = punctuated.replace(/\./g, ".\n")
|
||||
.replace(/\?/g, "?\n")
|
||||
.replace(/\!/g, "!\n");
|
||||
|
||||
this.targetEl.value = punctuated; // punctuated;
|
||||
this.simulateTypingWithEvents(this.targetEl, ' ', 0).then(() => {
|
||||
const chatInput = document.querySelector('chat-input');
|
||||
chatInput.finalizeMessage();
|
||||
});
|
||||
}
|
||||
|
||||
previousInterim = '';
|
||||
} else {
|
||||
if (alt.confidence >= 0.85) {
|
||||
interim += txt + ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interim && this.targetEl) {
|
||||
const el = this.targetEl;
|
||||
el.focus();
|
||||
|
||||
if (el.isContentEditable) {
|
||||
el.innerText = el.innerText.replace(new RegExp(previousInterim + '$'), '');
|
||||
} else {
|
||||
el.value = interim
|
||||
// el.value = interim
|
||||
//el.value = el.value.replace(new RegExp(previousInterim + '$'), interim);
|
||||
}
|
||||
document.querySelector('chat-input').sendMessage(interim);
|
||||
this.simulateTypingWithEvents(el, ' ', 0).then(() => {
|
||||
|
||||
})
|
||||
// this.simulateTypingWithEvents(el, interim, 0).then(() => {
|
||||
// previousInterim = interim;
|
||||
// });
|
||||
}
|
||||
};
|
||||
|
||||
this.recog.onend = () => {
|
||||
if (this.listening) {
|
||||
try {
|
||||
this.recog.start();
|
||||
} catch (e) {
|
||||
console.warn('Failed to restart speech recognition:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('click', () => this.toggle());
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, _, newVal) {}
|
||||
|
||||
get targetEl() {
|
||||
return document.querySelector("textarea") || null;
|
||||
}
|
||||
|
||||
connectedCallback() {}
|
||||
|
||||
get listening() {
|
||||
return this.hasAttribute('listening');
|
||||
}
|
||||
set listening(val) {
|
||||
if (val) this.setAttribute('listening', '');
|
||||
else this.removeAttribute('listening');
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!this.recog) return;
|
||||
if (this.listening) {
|
||||
this.recog.stop();
|
||||
this.listening = false;
|
||||
} else {
|
||||
try {
|
||||
this.recog.start();
|
||||
this.listening = true;
|
||||
} catch (e) {
|
||||
console.warn('Error starting recognition:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('stt-button', STTButton);
|
||||
|
@ -1,69 +0,0 @@
|
||||
class SnekSpeaker extends HTMLElement {
|
||||
|
||||
_enabled = false
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Optionally show something in the DOM
|
||||
this.shadowRoot.innerHTML = `<slot></slot>`;
|
||||
|
||||
this._utterance = new SpeechSynthesisUtterance();
|
||||
this._selectVoice();
|
||||
}
|
||||
toggle() {
|
||||
if (window.speechSynthesis.speaking) {
|
||||
window.speechSynthesis.pause();
|
||||
} else {
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
disable() {
|
||||
this._enabled = false
|
||||
}
|
||||
enable() {
|
||||
this._enabled = true
|
||||
}
|
||||
set enabled(val) {
|
||||
if (val) {
|
||||
this.enable()
|
||||
} else {
|
||||
this.disable()
|
||||
}
|
||||
}
|
||||
get enabled() {
|
||||
return this._enabled
|
||||
}
|
||||
_selectVoice() {
|
||||
const updateVoice = () => {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const maleEnglishVoices = voices.filter(voice =>
|
||||
voice.lang.startsWith('en') && voice.name.toLowerCase().includes('male')
|
||||
);
|
||||
if (maleEnglishVoices.length > 0) {
|
||||
this._utterance.voice = maleEnglishVoices[0];
|
||||
}
|
||||
};
|
||||
|
||||
updateVoice();
|
||||
// Some browsers load voices asynchronously
|
||||
window.speechSynthesis.onvoiceschanged = updateVoice;
|
||||
}
|
||||
|
||||
speak(text) {
|
||||
if(!this._enabled) return
|
||||
|
||||
if (!text) return;
|
||||
|
||||
this._utterance.text = text;
|
||||
window.speechSynthesis.speak(this._utterance);
|
||||
}
|
||||
}
|
||||
|
||||
// Define the element
|
||||
customElements.define('snek-speaker', SnekSpeaker);
|
||||
|
@ -112,9 +112,9 @@ class UploadButtonElement extends HTMLElement {
|
||||
this.channelUid = this.getAttribute("channel");
|
||||
this.uploadButton = this.container.querySelector(".upload-button");
|
||||
this.fileInput = this.container.querySelector(".hidden-input");
|
||||
/*this.uploadButton.addEventListener("click", () => {
|
||||
this.uploadButton.addEventListener("click", () => {
|
||||
this.fileInput.click();
|
||||
});*/
|
||||
});
|
||||
this.fileInput.addEventListener("change", () => {
|
||||
this.uploadFiles();
|
||||
});
|
||||
|
@ -14,7 +14,7 @@ class Cache:
|
||||
self.cache = {}
|
||||
self.max_items = max_items
|
||||
self.stats = {}
|
||||
self.enabled = True
|
||||
self.enabled = False
|
||||
self.lru = []
|
||||
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
||||
|
||||
|
@ -1,263 +0,0 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
|
||||
import yaml
|
||||
|
||||
try:
|
||||
import pty
|
||||
except Exception as ex:
|
||||
print("You are not able to run a terminal. See error:")
|
||||
print(ex)
|
||||
import os
|
||||
|
||||
|
||||
class ComposeFileManager:
|
||||
def __init__(self, compose_path="docker-compose.yml", event_handler=None):
|
||||
self.compose_path = compose_path
|
||||
self._load()
|
||||
self.running_instances = {}
|
||||
self.event_handler = event_handler
|
||||
|
||||
async def shutdown(self):
|
||||
print("Stopping all sessions")
|
||||
|
||||
tasks = []
|
||||
for name in self.list_instances():
|
||||
proc = self.running_instances.get(name)
|
||||
if not proc:
|
||||
continue
|
||||
if proc["proc"].returncode is None:
|
||||
print("Stopping", name)
|
||||
tasks.append(asyncio.create_task(proc["proc"].stop()))
|
||||
print("Stopped", name, "gracefully")
|
||||
return tasks
|
||||
|
||||
def _load(self):
|
||||
try:
|
||||
with open(self.compose_path) as f:
|
||||
self.compose = yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
self.compose = {"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())
|
||||
|
||||
async def _create_readers(self, container_name):
|
||||
instance = await self.get_instance(container_name)
|
||||
if not instance:
|
||||
return False
|
||||
proc = self.running_instances.get(container_name)
|
||||
if not proc:
|
||||
return False
|
||||
|
||||
async def reader(event_handler, stream):
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
line = await loop.run_in_executor(None, os.read, stream, 1024)
|
||||
if not line:
|
||||
break
|
||||
await event_handler(container_name, "stdout", line)
|
||||
await self.stop(container_name)
|
||||
|
||||
asyncio.create_task(reader(self.event_handler, proc["master"]))
|
||||
|
||||
def create_instance(
|
||||
self,
|
||||
name,
|
||||
image,
|
||||
command=None,
|
||||
cpus=None,
|
||||
memory=None,
|
||||
ports=None,
|
||||
volumes=None,
|
||||
):
|
||||
service = {
|
||||
"build": {
|
||||
"context": ".",
|
||||
"dockerfile": "DockerfileUbuntu",
|
||||
},
|
||||
"user": "root",
|
||||
"working_dir": "/home/retoor/projects/snek",
|
||||
"environment": [f"SNEK_UID={name}"],
|
||||
}
|
||||
service["command"] = command or "tail -f /dev/null"
|
||||
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()
|
||||
|
||||
async def get_instance(self, name):
|
||||
instance = self.compose.get("services", {}).get(name)
|
||||
if not instance:
|
||||
return None
|
||||
instance = json.loads(json.dumps(instance, default=str))
|
||||
instance["status"] = await self.get_instance_status(name)
|
||||
return instance
|
||||
|
||||
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()
|
||||
|
||||
async def get_instance_status(self, name):
|
||||
"""Asynchronously check the status of a docker-compose service instance."""
|
||||
if name not in self.list_instances():
|
||||
return "error"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
self.compose_path,
|
||||
"ps",
|
||||
"--services",
|
||||
"--filter",
|
||||
f"status=running",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
running_services = stdout.decode().split()
|
||||
print(running_services)
|
||||
return "running" if name in running_services else "stopped"
|
||||
|
||||
async def write_stdin(self, name, data):
|
||||
await self.event_handler(name, "stdin", data)
|
||||
proc = self.running_instances.get(name)
|
||||
if not proc:
|
||||
return False
|
||||
try:
|
||||
os.write(proc["master"], data.encode())
|
||||
return True
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
await self.stop(name)
|
||||
return False
|
||||
|
||||
async def stop(self, name):
|
||||
"""Asynchronously stop a container by doing 'docker compose stop [name]'."""
|
||||
if name not in self.list_instances():
|
||||
return False
|
||||
status = await self.get_instance_status(name)
|
||||
if status != "running":
|
||||
return True
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
self.compose_path,
|
||||
"stop",
|
||||
name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
if name in self.running_instances:
|
||||
del self.running_instances[name]
|
||||
stdout, stderr = None, None
|
||||
while proc.returncode is None:
|
||||
try:
|
||||
stdout, stderr = await proc.communicate()
|
||||
except Exception as ex:
|
||||
stdout = b""
|
||||
stderr = str(ex).encode()
|
||||
await self.event_handler(name, "stdout", stdout or stderr)
|
||||
print("Return code", proc.returncode)
|
||||
if stdout:
|
||||
await self.event_handler(name, "stdout", stdout or b"")
|
||||
return stdout and stdout.decode(errors="ignore") or ""
|
||||
|
||||
await self.event_handler(name, "stdout", stderr or b"")
|
||||
return stderr and stderr.decode(errors="ignore") or ""
|
||||
|
||||
async def start(self, name):
|
||||
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
|
||||
if name not in self.list_instances():
|
||||
return False
|
||||
|
||||
status = await self.get_instance_status(name)
|
||||
if (
|
||||
name in self.running_instances
|
||||
and status == "running"
|
||||
and self.running_instances.get(name)
|
||||
and self.running_instances.get(name).get("proc").returncode is None
|
||||
):
|
||||
return True
|
||||
elif name in self.running_instances:
|
||||
del self.running_instances[name]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
self.compose_path,
|
||||
"up",
|
||||
name,
|
||||
"-d",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = None, None
|
||||
while proc.returncode is None:
|
||||
try:
|
||||
stdout, stderr = await proc.communicate()
|
||||
except Exception as ex:
|
||||
stdout = b""
|
||||
stderr = str(ex).encode()
|
||||
if stdout:
|
||||
print(stdout.decode(errors="ignore"))
|
||||
if stderr:
|
||||
print(stderr.decode(errors="ignore"))
|
||||
await self.event_handler(name, "stdout", stdout or stderr)
|
||||
print("Return code", proc.returncode)
|
||||
|
||||
master, slave = pty.openpty()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
self.compose_path,
|
||||
"exec",
|
||||
name,
|
||||
"/usr/local/bin/entry",
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=slave,
|
||||
)
|
||||
proc = {"proc": proc, "master": master, "slave": slave}
|
||||
self.running_instances[name] = proc
|
||||
await self._create_readers(name)
|
||||
return True
|
@ -1,7 +1,6 @@
|
||||
DEFAULT_LIMIT = 30
|
||||
import asyncio
|
||||
import typing
|
||||
|
||||
import asyncio
|
||||
from snek.system.model import BaseModel
|
||||
|
||||
|
||||
@ -10,33 +9,22 @@ class BaseMapper:
|
||||
model_class: BaseModel = None
|
||||
default_limit: int = DEFAULT_LIMIT
|
||||
table_name: str = None
|
||||
semaphore = asyncio.Semaphore(1)
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
|
||||
self.default_limit = self.__class__.default_limit
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.app.db
|
||||
|
||||
@property
|
||||
@property
|
||||
def loop(self):
|
||||
return asyncio.get_event_loop()
|
||||
|
||||
async def run_in_executor(self, func, *args, **kwargs):
|
||||
use_semaphore = kwargs.pop("use_semaphore", False)
|
||||
|
||||
def _execute():
|
||||
result = func(*args, **kwargs)
|
||||
if use_semaphore:
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
return _execute()
|
||||
# async with self.semaphore:
|
||||
# return await self.loop.run_in_executor(None, _execute)
|
||||
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)
|
||||
@ -48,11 +36,8 @@ class BaseMapper:
|
||||
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
||||
if uid:
|
||||
kwargs["uid"] = uid
|
||||
if not kwargs.get("deleted_at"):
|
||||
kwargs["deleted_at"] = None
|
||||
# traceback.print_exc()
|
||||
|
||||
record = await self.db.get(self.table_name, kwargs)
|
||||
|
||||
record = await self.run_in_executor(self.table.find_one,**kwargs)
|
||||
if not record:
|
||||
return None
|
||||
record = dict(record)
|
||||
@ -60,56 +45,34 @@ class BaseMapper:
|
||||
for key, value in record.items():
|
||||
model[key] = value
|
||||
return model
|
||||
return await self.model_class.from_record(mapper=self, record=record)
|
||||
|
||||
async def exists(self, **kwargs):
|
||||
return await self.db.count(self.table_name, 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.db.count(self.table_name, kwargs)
|
||||
return await self.run_in_executor(self.table.count, **kwargs)
|
||||
|
||||
async def save(self, model: BaseModel) -> bool:
|
||||
if not model.record.get("uid"):
|
||||
raise Exception(f"Attempt to save without uid: {model.record}.")
|
||||
model.updated_at.update()
|
||||
await self.upsert(model)
|
||||
return model
|
||||
# return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
|
||||
return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
|
||||
|
||||
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
||||
if not kwargs.get("_limit"):
|
||||
kwargs["_limit"] = self.default_limit
|
||||
if not kwargs.get("deleted_at"):
|
||||
kwargs["deleted_at"] = None
|
||||
for record in await self.db.find(self.table_name, kwargs):
|
||||
for record in await self.run_in_executor(self.table.find, **kwargs):
|
||||
model = await self.new()
|
||||
for key, value in record.items():
|
||||
model[key] = value
|
||||
yield model
|
||||
|
||||
async def _use_semaphore(self, sql):
|
||||
sql = sql.lower().strip()
|
||||
return "insert" in sql or "update" in sql or "delete" in sql
|
||||
|
||||
async def query(self, sql, *args):
|
||||
for record in await 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):
|
||||
if model["deleted_at"] is not None:
|
||||
raise Exception("Can't update deleted record.")
|
||||
model.updated_at.update()
|
||||
return await self.db.update(
|
||||
self.table_name, model.record, {"uid": model["uid"]}
|
||||
)
|
||||
|
||||
async def upsert(self, model):
|
||||
model.updated_at.update()
|
||||
await self.db.upsert(self.table_name, model.record, {"uid": model["uid"]})
|
||||
return model
|
||||
|
||||
async def delete(self, **kwargs) -> int:
|
||||
if not kwargs or not isinstance(kwargs, dict):
|
||||
raise Exception("Can't execute delete with no filter.")
|
||||
return await self.db.delete(self.table_name, kwargs)
|
||||
return await self.run_in_executor(self.table.delete, **kwargs)
|
||||
|
@ -1,8 +1,8 @@
|
||||
# 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, time_cache_async
|
||||
from app.cache import time_cache_async
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from mistune.plugins.formatting import strikethrough
|
||||
from mistune.plugins.spoiler import spoiler
|
||||
@ -12,24 +12,9 @@ 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 = False
|
||||
_allow_harmful_protocols = True
|
||||
|
||||
def __init__(self, app, template):
|
||||
super().__init__(False, True)
|
||||
@ -41,8 +26,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:
|
||||
@ -50,7 +35,6 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
except:
|
||||
return get_lexer_by_name(default, stripall=True)
|
||||
|
||||
@time_cache(timeout=60 * 60)
|
||||
def block_code(self, code, lang=None, info=None):
|
||||
if not lang:
|
||||
lang = info
|
||||
|
@ -1,22 +1,14 @@
|
||||
# Written by retoor@molodetz.nl
|
||||
|
||||
# This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers.
|
||||
# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers.
|
||||
|
||||
import secrets
|
||||
# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library.
|
||||
|
||||
# MIT License: This code is distributed under the MIT License.
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def csp_middleware(request, handler):
|
||||
nonce = secrets.token_hex(16)
|
||||
request.headers.get("Origin")
|
||||
request["csp_nonce"] = nonce
|
||||
response = await handler(request)
|
||||
# response.headers['Content-Security-Policy'] = csp_policy
|
||||
return response
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def no_cors_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
@ -41,7 +33,7 @@ async def auth_middleware(request, handler):
|
||||
request["user"] = None
|
||||
if request.session.get("uid") and request.session.get("logged_in"):
|
||||
request["user"] = await request.app.services.user.get(
|
||||
uid=request.session.get("uid")
|
||||
uid=request.app.session.get("uid")
|
||||
)
|
||||
return await handler(request)
|
||||
|
||||
@ -58,5 +50,4 @@ async def cors_middleware(request, handler):
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
|
||||
return response
|
||||
|
@ -255,9 +255,6 @@ class BaseModel:
|
||||
def mapper(self):
|
||||
return self._mapper
|
||||
|
||||
def save(self):
|
||||
return self.mapper.save(self)
|
||||
|
||||
@mapper.setter
|
||||
def mapper(self, value):
|
||||
self._mapper = value
|
||||
|
@ -63,11 +63,9 @@ 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.
|
||||
|
||||
@ -80,6 +78,5 @@ 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)
|
||||
|
@ -1,4 +1,5 @@
|
||||
from snek.mapper import get_mapper
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -25,9 +26,6 @@ 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)
|
||||
|
||||
@ -35,27 +33,23 @@ class BaseService:
|
||||
return await self.mapper.new()
|
||||
|
||||
async def query(self, sql, *args):
|
||||
for record in await self.app.db.query(sql, *args):
|
||||
for record in self.app.db.query(sql, *args):
|
||||
yield record
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
if "deleted_at" not in kwargs:
|
||||
kwargs["deleted_at"] = None
|
||||
uid = kwargs.get("uid")
|
||||
if args:
|
||||
uid = args[0]
|
||||
if uid or "uid" in kwargs:
|
||||
result = await self.cache.get(uid)
|
||||
if result and result.__class__ == self.mapper.model_class:
|
||||
return result
|
||||
async def get(self, uid=None, **kwargs):
|
||||
if uid:
|
||||
if not kwargs:
|
||||
result = await self.cache.get(uid)
|
||||
if False and result and result.__class__ == self.mapper.model_class:
|
||||
return result
|
||||
kwargs["uid"] = uid
|
||||
print(kwargs, "ZZZZZZZ")
|
||||
|
||||
result = await self.mapper.get(**kwargs)
|
||||
if result:
|
||||
await self.cache.set(result["uid"], result)
|
||||
return result
|
||||
|
||||
async def save(self, model):
|
||||
async def save(self, model: UserModel):
|
||||
# if model.is_valid: You Know why not
|
||||
if await self.mapper.save(model):
|
||||
await self.cache.set(model["uid"], model)
|
||||
|
@ -1,142 +0,0 @@
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
|
||||
|
||||
def create_stats_structure():
|
||||
"""Creates the nested dictionary structure for storing statistics."""
|
||||
|
||||
def nested_dd():
|
||||
return defaultdict(lambda: defaultdict(int))
|
||||
|
||||
return defaultdict(nested_dd)
|
||||
|
||||
|
||||
def get_time_keys(dt: datetime):
|
||||
"""Generates dictionary keys for different time granularities."""
|
||||
return {
|
||||
"hour": dt.strftime("%Y-%m-%d-%H"),
|
||||
"day": dt.strftime("%Y-%m-%d"),
|
||||
"week": dt.strftime("%Y-%W"), # Week number, Monday is first day
|
||||
"month": dt.strftime("%Y-%m"),
|
||||
}
|
||||
|
||||
|
||||
def update_stats_counters(stats_dict: defaultdict, now: datetime):
|
||||
"""Increments the appropriate time-based counters in a stats dictionary."""
|
||||
keys = get_time_keys(now)
|
||||
stats_dict["by_hour"][keys["hour"]] += 1
|
||||
stats_dict["by_day"][keys["day"]] += 1
|
||||
stats_dict["by_week"][keys["week"]] += 1
|
||||
stats_dict["by_month"][keys["month"]] += 1
|
||||
|
||||
|
||||
def generate_time_series_svg(
|
||||
title: str, data: list[tuple[str, int]], y_label: str
|
||||
) -> str:
|
||||
"""Generates a responsive SVG bar chart for time-series data."""
|
||||
if not data:
|
||||
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
|
||||
max_val = max(item[1] for item in data) if data else 1
|
||||
svg_height, svg_width = 250, 600
|
||||
bar_padding = 5
|
||||
bar_width = (svg_width - 50) / len(data) - bar_padding
|
||||
|
||||
bars = ""
|
||||
labels = ""
|
||||
for i, (key, val) in enumerate(data):
|
||||
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
|
||||
x = i * (bar_width + bar_padding) + 40
|
||||
y = svg_height - bar_height - 30
|
||||
|
||||
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
|
||||
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
|
||||
|
||||
return f"""
|
||||
<h3>{html.escape(title)}</h3>
|
||||
<div style="border:1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||
<svg viewBox="0 0 {svg_width} {svg_height}" style="width:100%; height:auto;">
|
||||
<g>{bars}</g>
|
||||
<g>{labels}</g>
|
||||
<line x1="35" y1="10" x2="35" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||
<line x1="35" y1="{svg_height - 30}" x2="{svg_width - 10}" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||
<text x="5" y="{svg_height - 30}" font-size="12">0</text>
|
||||
<text x="5" y="20" font-size="12">{max_val}</text>
|
||||
</svg>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def middleware(request, handler):
|
||||
"""Middleware to count all incoming HTTP requests."""
|
||||
# Avoid counting requests to the stats page itself
|
||||
if request.path.startswith("/stats.html"):
|
||||
return await handler(request)
|
||||
|
||||
update_stats_counters(
|
||||
request.app["stats"]["http_requests"], datetime.now(timezone.utc)
|
||||
)
|
||||
return await handler(request)
|
||||
|
||||
|
||||
def update_websocket_stats(app):
|
||||
update_stats_counters(
|
||||
app["stats"]["websocket_requests"], datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
|
||||
"""This function proxies WebSocket messages AND counts them."""
|
||||
async for msg in ws_from:
|
||||
# This is the key part for monitoring WebSockets
|
||||
update_stats_counters(stats_dict, datetime.now(timezone.utc))
|
||||
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
await ws_to.send_str(msg.data)
|
||||
elif msg.type == WSMsgType.BINARY:
|
||||
await ws_to.send_bytes(msg.data)
|
||||
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
|
||||
await ws_to.close(code=ws_from.close_code)
|
||||
break
|
||||
|
||||
|
||||
async def stats_handler(request: web.Request):
|
||||
"""Handler to display the statistics dashboard."""
|
||||
stats = request.app["stats"]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Helper to prepare data for charts
|
||||
def get_data(source, period, count):
|
||||
data = []
|
||||
for i in range(count - 1, -1, -1):
|
||||
if period == "hour":
|
||||
dt = now - timedelta(hours=i)
|
||||
key, label = dt.strftime("%Y-%m-%d-%H"), dt.strftime("%H:00")
|
||||
data.append((label, source["by_hour"].get(key, 0)))
|
||||
elif period == "day":
|
||||
dt = now - timedelta(days=i)
|
||||
key, label = dt.strftime("%Y-%m-%d"), dt.strftime("%a")
|
||||
data.append((label, source["by_day"].get(key, 0)))
|
||||
return data
|
||||
|
||||
http_hourly = get_data(stats["http_requests"], "hour", 24)
|
||||
ws_hourly = get_data(stats["ws_messages"], "hour", 24)
|
||||
http_daily = get_data(stats["http_requests"], "day", 7)
|
||||
ws_daily = get_data(stats["ws_messages"], "day", 7)
|
||||
|
||||
body = f"""
|
||||
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
|
||||
<body>
|
||||
<h2>Application Dashboard</h2>
|
||||
<h3>Last 24 Hours</h3>
|
||||
{generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")}
|
||||
{generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")}
|
||||
<h3>Last 7 Days</h3>
|
||||
{generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")}
|
||||
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
|
||||
</body></html>
|
||||
"""
|
||||
return web.Response(text=body, content_type="text/html")
|
@ -1,12 +1,11 @@
|
||||
import mimetypes
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import bleach
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from app.cache import time_cache
|
||||
import emoji
|
||||
import requests
|
||||
from app.cache import time_cache
|
||||
from bs4 import BeautifulSoup
|
||||
from jinja2 import TemplateSyntaxError, nodes
|
||||
from jinja2.ext import Extension
|
||||
@ -79,43 +78,6 @@ emoji.EMOJI_DATA[
|
||||
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
||||
|
||||
|
||||
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
|
||||
|
||||
|
||||
def sanitize_html(value):
|
||||
|
||||
soup = BeautifulSoup(value, "html.parser")
|
||||
|
||||
for script in soup.find_all("script"):
|
||||
script.decompose()
|
||||
|
||||
# for iframe in soup.find_all('iframe'):
|
||||
# iframe.decompose()
|
||||
|
||||
for tag in soup.find_all(["object", "embed"]):
|
||||
tag.decompose()
|
||||
|
||||
for tag in soup.find_all():
|
||||
event_attributes = ["onclick", "onerror", "onload", "onmouseover", "onfocus"]
|
||||
for attr in event_attributes:
|
||||
if attr in tag.attrs:
|
||||
del tag[attr]
|
||||
|
||||
for img in soup.find_all("img"):
|
||||
if "onerror" in img.attrs:
|
||||
img.decompose()
|
||||
|
||||
return soup.prettify()
|
||||
|
||||
|
||||
def sanitize_html2(value):
|
||||
return bleach.clean(
|
||||
value,
|
||||
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def set_link_target_blank(text):
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
|
||||
@ -128,10 +90,6 @@ def set_link_target_blank(text):
|
||||
return str(soup)
|
||||
|
||||
|
||||
def whitelist_attributes(html):
|
||||
return sanitize_html(html)
|
||||
|
||||
|
||||
def embed_youtube(text):
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
for element in soup.find_all("a"):
|
||||
@ -142,12 +100,11 @@ def embed_youtube(text):
|
||||
or url.hostname
|
||||
in [
|
||||
"www.youtube.com",
|
||||
"music.youtube.com",
|
||||
"youtube.com",
|
||||
"www.youtube-nocookie.com",
|
||||
"youtube-nocookie.com",
|
||||
]
|
||||
and any(url.path.startswith(p) for p in ["/watch", "/embed", "/shorts"])
|
||||
and any(url.path.startswith(p) for p in ["/watch", "/embed"])
|
||||
):
|
||||
queries = parse_qs(url.query)
|
||||
if "v" in queries:
|
||||
@ -227,12 +184,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)
|
||||
|
||||
@ -276,7 +233,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)
|
||||
@ -287,205 +244,135 @@ def get_url_content(url):
|
||||
return None
|
||||
|
||||
|
||||
def get_element_options(head_info, elem=None, meta=None, ograph=None, twitter=None):
|
||||
if twitter:
|
||||
tw_tag = head_info.find(
|
||||
"meta", attrs={"name": "twitter:" + twitter}
|
||||
) or head_info.find("meta", attrs={"property": "twitter:" + twitter})
|
||||
if tw_tag:
|
||||
return tw_tag.get("content", tw_tag.get("value", None))
|
||||
|
||||
if ograph:
|
||||
og_tag = head_info.find(
|
||||
"meta", attrs={"property": "og:" + ograph}
|
||||
) or head_info.find("meta", attrs={"name": "og:" + ograph})
|
||||
if og_tag:
|
||||
return og_tag.get("content", og_tag.get("value", None))
|
||||
|
||||
if meta:
|
||||
meta_tag = head_info.find("meta", attrs={"name": meta}) or head_info.find(
|
||||
"meta", attrs={"property": meta}
|
||||
)
|
||||
if meta_tag:
|
||||
return meta_tag.get("content", meta_tag.get("value", None))
|
||||
|
||||
if elem:
|
||||
elem_tag = head_info.find(elem)
|
||||
if elem_tag:
|
||||
return elem_tag.text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def embed_url(text):
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
|
||||
attachments = {}
|
||||
|
||||
for element in soup.find_all("a"):
|
||||
if (
|
||||
"href" in element.attrs
|
||||
and element.attrs["href"].startswith("http")
|
||||
and element.attrs["href"] not in attachments
|
||||
and ("data-noembed" not in element.attrs)
|
||||
):
|
||||
original_link_name = element.attrs["href"]
|
||||
|
||||
if "href" in element.attrs and element.attrs["href"].startswith("http"):
|
||||
page_url = urlparse(element.attrs["href"])
|
||||
page = get_url_content(element.attrs["href"])
|
||||
if not page:
|
||||
continue
|
||||
if page:
|
||||
parsed_page = BeautifulSoup(page, "html.parser")
|
||||
head_info = parsed_page.find("head")
|
||||
if head_info:
|
||||
|
||||
parsed_page = BeautifulSoup(page, "html.parser")
|
||||
head_info = parsed_page.find("head")
|
||||
def get_element_options(
|
||||
elem=None, meta=None, ograph=None, twitter=None
|
||||
):
|
||||
if twitter:
|
||||
tw_tag = head_info.find(
|
||||
"meta", attrs={"name": "twitter:" + twitter}
|
||||
) or head_info.find(
|
||||
"meta", attrs={"property": "twitter:" + twitter}
|
||||
)
|
||||
if tw_tag:
|
||||
return tw_tag.get("content", tw_tag.get("value", None))
|
||||
|
||||
if not head_info:
|
||||
continue
|
||||
if ograph:
|
||||
og_tag = head_info.find(
|
||||
"meta", attrs={"property": "og:" + ograph}
|
||||
) or head_info.find("meta", attrs={"name": "og:" + ograph})
|
||||
if og_tag:
|
||||
return og_tag.get("content", og_tag.get("value", None))
|
||||
|
||||
page_name = (
|
||||
get_element_options(head_info, "title", "title", "title", "title")
|
||||
or page_url.netloc
|
||||
)
|
||||
page_site = (
|
||||
get_element_options(head_info, None, "site", "site", "site")
|
||||
or get_element_options(head_info, ograph="site_name")
|
||||
or page_url.netloc
|
||||
)
|
||||
page_description = get_element_options(
|
||||
head_info, None, "description", "description", "description"
|
||||
)
|
||||
if meta:
|
||||
meta_tag = head_info.find(
|
||||
"meta", attrs={"name": meta}
|
||||
) or head_info.find("meta", attrs={"property": meta})
|
||||
if meta_tag:
|
||||
return meta_tag.get(
|
||||
"content", meta_tag.get("value", None)
|
||||
)
|
||||
|
||||
page_image = (
|
||||
get_element_options(
|
||||
head_info,
|
||||
None,
|
||||
"image:secure_url",
|
||||
"image:secure_url",
|
||||
"image:secure_url",
|
||||
)
|
||||
or get_element_options(
|
||||
head_info, None, "image:url", "image:url", "image:url"
|
||||
)
|
||||
or get_element_options(head_info, None, "image", "image", "image")
|
||||
)
|
||||
page_image_height = get_element_options(
|
||||
head_info, None, "image:height", "image:height", "image:height"
|
||||
)
|
||||
page_image_width = get_element_options(
|
||||
head_info, None, "image:width", "image:width", "image:width"
|
||||
)
|
||||
page_image_alt = get_element_options(
|
||||
head_info, None, "image:alt", "image:alt", "image:alt"
|
||||
)
|
||||
if elem:
|
||||
elem_tag = head_info.find(elem)
|
||||
if elem_tag:
|
||||
return elem_tag.text
|
||||
|
||||
page_video = (
|
||||
get_element_options(
|
||||
head_info,
|
||||
None,
|
||||
"video:secure_url",
|
||||
"video:secure_url",
|
||||
"video:secure_url",
|
||||
)
|
||||
or get_element_options(
|
||||
head_info, None, "video:url", "video:url", "video:url"
|
||||
)
|
||||
or get_element_options(head_info, None, "video", "video", "video")
|
||||
)
|
||||
page_video_height = get_element_options(
|
||||
head_info, None, "video:height", "video:height", "video:height"
|
||||
)
|
||||
page_video_width = get_element_options(
|
||||
head_info, None, "video:width", "video:width", "video:width"
|
||||
)
|
||||
page_video_type = get_element_options(
|
||||
head_info, None, "video:type", "video:type", "video:type"
|
||||
)
|
||||
return None
|
||||
|
||||
page_audio = (
|
||||
get_element_options(
|
||||
head_info,
|
||||
None,
|
||||
"audio:secure_url",
|
||||
"audio:secure_url",
|
||||
"audio:secure_url",
|
||||
)
|
||||
or get_element_options(
|
||||
head_info, None, "audio:url", "audio:url", "audio:url"
|
||||
)
|
||||
or get_element_options(head_info, None, "audio", "audio", "audio")
|
||||
)
|
||||
original_link_name = element.attrs["href"]
|
||||
|
||||
page_player = get_element_options(head_info, twitter="player")
|
||||
page_player_width = get_element_options(head_info, twitter="player:width")
|
||||
page_player_height = get_element_options(head_info, twitter="player:height")
|
||||
if original_link_name in attachments:
|
||||
continue
|
||||
|
||||
(get_element_options(head_info, twitter="card") or "summary_large_image")
|
||||
page_name = (
|
||||
get_element_options("title", "title", "title", "title")
|
||||
or page_url.netloc
|
||||
)
|
||||
page_site = (
|
||||
get_element_options(None, "site", "site", "site")
|
||||
or page_url.netloc
|
||||
)
|
||||
page_description = get_element_options(
|
||||
None, "description", "description", "description"
|
||||
)
|
||||
page_image = get_element_options(None, "image", "image", "image")
|
||||
page_image_alt = get_element_options(
|
||||
None, "image:alt", "image:alt", "image:alt"
|
||||
)
|
||||
page_video = get_element_options(None, "video", "video", "video")
|
||||
page_audio = get_element_options(None, "audio", "audio", "audio")
|
||||
|
||||
attachment_base = BeautifulSoup(str(element), "html.parser")
|
||||
attachments[original_link_name] = attachment_base
|
||||
preview_size = (
|
||||
get_element_options(None, None, None, "card")
|
||||
or "summary_large_image"
|
||||
)
|
||||
|
||||
attachment = next(attachment_base.children)
|
||||
|
||||
attachment.clear()
|
||||
attachment.attrs["class"] = "embed-url-link"
|
||||
attachment_base = BeautifulSoup(str(element), "html.parser")
|
||||
attachments[original_link_name] = attachment_base
|
||||
|
||||
render_element = attachment
|
||||
attachment = next(attachment_base.children)
|
||||
|
||||
if page_player:
|
||||
style = {
|
||||
"width": page_player_width + "px" if page_player_width else None,
|
||||
"height": f"{(page_player_height or '400')}px",
|
||||
}
|
||||
attachment.clear()
|
||||
attachment.attrs["class"] = "embed-url-link"
|
||||
|
||||
style_string = "; ".join(
|
||||
f"{key}: {value}" for key, value in style.items() if value
|
||||
)
|
||||
player_template = f'<iframe src="{page_player}" style="{style_string}" title="{page_name}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
|
||||
render_element.append(BeautifulSoup(player_template, "html.parser"))
|
||||
elif page_video:
|
||||
style = {
|
||||
"width": page_video_width + "px" if page_video_width else None,
|
||||
"height": f"{(page_video_height or '400')}px",
|
||||
}
|
||||
render_element = attachment
|
||||
|
||||
style_string = "; ".join(
|
||||
f"{key}: {value}" for key, value in style.items() if value
|
||||
)
|
||||
if not page_video_type or page_video_type.startswith("video/"):
|
||||
video_template = f'<video style="{style_string}" controls><source src="{page_video}" type="{page_video_type}">Your browser does not support the video tag.</video>'
|
||||
else:
|
||||
video_template = f'<iframe style="{style_string}" src="{page_video}" title="{page_name}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
|
||||
render_element.append(BeautifulSoup(video_template, "html.parser"))
|
||||
elif page_image:
|
||||
style = {
|
||||
"width": page_image_width + "px" if page_image_width else None,
|
||||
"height": page_image_height + "px" if page_image_height else None,
|
||||
}
|
||||
if page_image:
|
||||
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
|
||||
render_element.append(
|
||||
BeautifulSoup(image_template, "html.parser")
|
||||
)
|
||||
if page_video:
|
||||
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
|
||||
render_element.append(
|
||||
BeautifulSoup(video_template, "html.parser")
|
||||
)
|
||||
if page_audio:
|
||||
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
|
||||
render_element.append(
|
||||
BeautifulSoup(audio_template, "html.parser")
|
||||
)
|
||||
|
||||
style_string = "; ".join(
|
||||
f"{key}: {value}" for key, value in style.items() if value
|
||||
)
|
||||
description_element_base = BeautifulSoup(
|
||||
"<span class='description'></span>", "html.parser"
|
||||
)
|
||||
description_element = next(description_element_base.children)
|
||||
description_element.append(
|
||||
BeautifulSoup(
|
||||
f'<p class="page-site">{page_site}</p>',
|
||||
"html.parser",
|
||||
)
|
||||
)
|
||||
|
||||
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="1" height="1" style="{style_string}" /></span>'
|
||||
render_element.append(BeautifulSoup(image_template, "html.parser"))
|
||||
description_element.append(
|
||||
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
|
||||
)
|
||||
|
||||
if page_audio:
|
||||
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
|
||||
render_element.append(BeautifulSoup(audio_template, "html.parser"))
|
||||
description_element.append(
|
||||
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
|
||||
)
|
||||
|
||||
description_element = BeautifulSoup(
|
||||
f"""
|
||||
<span class='description'>
|
||||
<p class="page-site">{page_site}</p>
|
||||
<strong class="page-name">{page_name}</strong>
|
||||
<p class='page-description'>{page_description or "No description available."}</p>
|
||||
<p class='page-original-link'>{original_link_name}</p>
|
||||
</span>
|
||||
""",
|
||||
"html.parser",
|
||||
)
|
||||
description_element.append(
|
||||
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
|
||||
)
|
||||
|
||||
render_element.append(description_element_base)
|
||||
|
||||
render_element.append(description_element)
|
||||
|
||||
for attachment in attachments.values():
|
||||
soup.append(attachment)
|
||||
|
@ -1,24 +1,22 @@
|
||||
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.hostname = hostname
|
||||
self.port = port
|
||||
self.connect()
|
||||
|
||||
self.connect()
|
||||
|
||||
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"
|
||||
@ -27,58 +25,57 @@ 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):
|
||||
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 :]
|
||||
return data
|
||||
|
||||
data = self.buffer[:find_pos+4]
|
||||
self.buffer = self.buffer[find_pos+4:]
|
||||
return data
|
||||
|
||||
chunk = self.socket.recv(1024)
|
||||
if not chunk:
|
||||
return None
|
||||
self.buffer += chunk
|
||||
|
||||
|
||||
def read_exactly(self, length):
|
||||
while len(self.buffer) < length:
|
||||
chunk = self.socket.recv(length - len(self.buffer))
|
||||
if not chunk:
|
||||
return None
|
||||
self.buffer += chunk
|
||||
response = self.buffer[:length]
|
||||
self.buffer += chunk
|
||||
response = self.buffer[: length]
|
||||
self.buffer = self.buffer[length:]
|
||||
return response
|
||||
|
||||
def read(self):
|
||||
frame = None
|
||||
frame = None
|
||||
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()
|
||||
|
||||
|
||||
|
@ -1,220 +1,10 @@
|
||||
<!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—RocketChat’s commercial pivot, Slack’s 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>
|
||||
Snek’s 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>© 2025 Snek – Engineered for business. Secure, resilient, and built for those who demand professional-grade software.</p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.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,35 +6,37 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Snek</title>
|
||||
<style>{{highlight_styles}}</style>
|
||||
<link rel="stylesheet" href="/file-upload-grid.css">
|
||||
<script src="/njet.js" type="module"></script>
|
||||
<script src="/tts.js" type="module"></script>
|
||||
<script src="/stt.js" type="module"></script>
|
||||
<script src="/file-upload-grid.js" type="module"></script>
|
||||
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
||||
|
||||
<script src="/push.js" type="module"></script>
|
||||
<!--
|
||||
<script src="/push.js"></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>
|
||||
<script src="/html-frame.js" type="module"></script>
|
||||
<script src="/app.js" type="module"></script>
|
||||
<script src="/editor.js" type="module"></script>
|
||||
<script src="/file-manager.js" type="module"></script>
|
||||
<script src="/user-list.js"></script>
|
||||
<script src="/message-list.js" type="module"></script>
|
||||
<script src="/chat-input.js" type="module"></script>
|
||||
<script src="/container.js" type="module"></script>
|
||||
<script src="/dumb-term.js" type="module"></script>
|
||||
<link rel="stylesheet" href="/sandbox.css">
|
||||
<link rel="stylesheet" href="/user-list.css">
|
||||
<link rel="stylesheet" href="/fa640.min.css">
|
||||
|
||||
<link rel="stylesheet" href="/base.css">
|
||||
<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 nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
||||
<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">
|
||||
<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">
|
||||
@ -44,7 +46,6 @@
|
||||
<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>
|
||||
|
||||
@ -53,102 +54,13 @@
|
||||
{% block sidebar %}
|
||||
{% include "sidebar_channels.html" %}
|
||||
{% endblock %}
|
||||
<main>
|
||||
|
||||
<main>
|
||||
|
||||
{% block main %}
|
||||
<chat-window class="chat-area"></chat-window>
|
||||
{% endblock %}
|
||||
</main>
|
||||
<script type="module">
|
||||
import { app } from "/app.js";
|
||||
import { Container } from "/container.js";
|
||||
let prevKey = null;
|
||||
|
||||
function toggleDevelopmentMode(){
|
||||
const headerElement = document.querySelector('header');
|
||||
headerElement.style.display = 'none';
|
||||
const sidebarElement = document.querySelector('aside');
|
||||
|
||||
sidebarElement.style.position = 'fixed'
|
||||
sidebarElement.style.width= '10%'
|
||||
sidebarElement.style.top = '0px'
|
||||
sidebarElement.style.left='0px'
|
||||
sidebarElement.style.height='100%'
|
||||
|
||||
// sidebarElement.style.display = 'none';
|
||||
const containerElement = document.querySelector('#terminal');
|
||||
containerElement.style.position = 'fixed';
|
||||
containerElement.style.width = '50%';
|
||||
containerElement.style.height = '100%';
|
||||
containerElement.style.left = '10%';
|
||||
containerElement.style.top = '0px';
|
||||
//window.container.resizeToPercentage(document.body,'50%','100%')
|
||||
|
||||
const messagesElement = document.querySelector('.chat-area');
|
||||
messagesElement.style.position = 'fixed';
|
||||
messagesElement.style.width = '40%';
|
||||
messagesElement.style.height = '100%';
|
||||
messagesElement.style.left = '60%';
|
||||
messagesElement.style.top = '0px';
|
||||
|
||||
const messageList = document.querySelector('message-list')
|
||||
messageList.scrollToBottom()
|
||||
|
||||
window.container.fit()
|
||||
|
||||
app.starField.renderWord("H4x0r 1337")
|
||||
}
|
||||
|
||||
{% if channel %}
|
||||
app.channelUid = '{{ channel.uid.value }}'
|
||||
window.getContainer = async function (){
|
||||
if(window.c) return window.c
|
||||
window.c = new Container(app.channelUid,false)
|
||||
window.c.start()
|
||||
window.t = document.querySelector("#terminal")
|
||||
window.t.classList.toggle("hidden")
|
||||
window.c.render(window.t)
|
||||
|
||||
|
||||
return window.c
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
|
||||
document.addEventListener("keydown", async(event) => {
|
||||
if(prevKey == "Escape"){
|
||||
document.querySelector("chat-input").querySelector("textarea").value = "";
|
||||
}
|
||||
if(prevKey == "Escape" && event.key == "t"){
|
||||
app.starField.shuffleAll(5000)
|
||||
|
||||
}
|
||||
if(event.key == "." && event.ctrlKey){
|
||||
event.preventDefault();
|
||||
if(!window.c)
|
||||
{
|
||||
window.getContainer()
|
||||
}
|
||||
if(window.c){
|
||||
|
||||
toggleDevelopmentMode()
|
||||
//window.container.terminal.element.hidden = !window.container.terminal.element.hidden
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
prevKey = event.key
|
||||
if(event.key == "," && event.ctrlKey){
|
||||
event.preventDefault();
|
||||
let textAreas = document.querySelectorAll("textarea")
|
||||
textAreas.forEach(textArea => {
|
||||
if(document.activeElement != textArea)
|
||||
setTimeout(() => textArea.focus(), 10)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
<script>
|
||||
let installPrompt = null
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
//e.preventDefault();
|
||||
@ -160,6 +72,7 @@ let installPrompt = null
|
||||
|
||||
button.addEventListener("click", async ()=>{
|
||||
const result = await installPrompt.prompt()
|
||||
console.info(result.outcome)
|
||||
})
|
||||
|
||||
|
||||
|
@ -10,7 +10,6 @@
|
||||
<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>
|
||||
@ -35,7 +34,6 @@
|
||||
<main>
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
{% include "sandbox.html" %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|