Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a54848f1 | ||
|
|
015337d75c | ||
|
|
5f06d7e04c | ||
|
|
1b2ad3965b | ||
|
|
7adb71efe5 | ||
|
|
f35fb12856 | ||
|
|
0295108e6b | ||
|
|
a2fa976065 | ||
|
|
b82db127ae | ||
|
|
f5e807cdbf | ||
|
|
06f28fd426 | ||
|
|
60afefac96 | ||
|
|
351fef504c | ||
|
|
3a8703d3c6 | ||
|
|
1c58165425 | ||
|
|
38640d8f75 | ||
|
|
bb1f7cdb88 | ||
|
|
4bb1b0997a | ||
|
|
f8f1235e60 | ||
|
|
fbd72f727a | ||
|
|
62eb1060d9 | ||
|
|
2fc18801a7 | ||
|
|
f0d2a7cc05 | ||
|
|
2808952153 | ||
|
|
149659064d | ||
|
|
cb310967cd | ||
|
|
3e7cb9387c | ||
|
|
cef451cb17 | ||
|
|
da9566c11f | ||
|
|
d3c0e138d8 | ||
|
|
7afc24ce51 | ||
|
|
92573ebeb4 | ||
|
|
987bd3a1c7 | ||
|
|
a95a09a062 | ||
|
|
7b2c93bcef | ||
|
|
f02058b0c0 | ||
|
|
f35742fec3 | ||
|
|
7b08e6a45e | ||
|
|
e99cceaa52 | ||
|
|
df8c3f1e09 | ||
|
|
b65ec449a0 | ||
|
|
bb9d763416 | ||
|
|
bdddbf678c | ||
|
|
389d417c1b | ||
|
|
f182c2209e | ||
|
|
8e9ee4bff0 | ||
|
|
7f47a21d40 | ||
|
|
7914511de5 | ||
|
|
efbc6a9b4c | ||
|
|
57ac8e772b | ||
|
|
3589f42651 | ||
|
|
1052010dd5 | ||
|
|
ae26181cf8 | ||
|
|
66b36509d2 | ||
|
|
e49062a9db | ||
|
|
9e56ff8494 | ||
|
|
831b5c17cd | ||
|
|
d04ea8549d | ||
|
|
35786703d5 | ||
|
|
10eec5fd6d | ||
|
|
d4debeab74 | ||
|
|
5c0ea360cd | ||
|
|
3b38e30df1 | ||
|
|
9a39bedd3a | ||
|
|
9e1eb9f1e5 | ||
|
|
82f8a1ef4a | ||
|
|
7c815898ea | ||
|
|
58a951eec9 | ||
|
|
ef75cb3341 | ||
|
|
1c71c0016b | ||
|
|
1a034041ab | ||
|
|
c60f9ff4d3 | ||
|
|
3efe388d3f | ||
|
|
7dc12c9e7f | ||
|
|
19c88d786e | ||
|
|
9937f532ec | ||
|
|
e380a1b9e7 | ||
|
|
13476bddf6 | ||
|
|
31d08ec973 | ||
|
|
deaa7716a2 | ||
|
|
157493b0f4 | ||
|
|
f7e1708039 | ||
|
|
20f817506f | ||
|
|
94b9d2c63b | ||
|
|
fcc2d7b748 | ||
|
|
24ddd4b294 | ||
|
|
557b34b71a | ||
|
|
e0255b28ec | ||
|
|
69855fa118 | ||
|
|
a07f2680d6 | ||
|
|
0738b1ff91 | ||
|
|
388f8bc508 | ||
|
|
a17bdc7e13 | ||
|
|
5711618e6e | ||
|
|
d022cff499 | ||
|
|
d4a480b5ea | ||
|
|
161ff392d7 | ||
|
|
4e72fbf84b | ||
|
|
20dd16734f | ||
|
|
b01665f02c | ||
|
|
aec2da11f2 | ||
|
|
272998f757 | ||
|
|
744d0ace84 | ||
|
|
326c549670 | ||
|
|
d966c9529b | ||
|
|
4350714534 | ||
|
|
1a26cacb66 | ||
|
|
0057792802 |
@@ -1,3 +1,5 @@
|
|||||||
|
|
||||||
|
snek-container-compose.yml
|
||||||
.r_history
|
.r_history
|
||||||
.vscode
|
.vscode
|
||||||
.history
|
.history
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ RUN chmod +x r
|
|||||||
|
|
||||||
RUN mv r /usr/local/bin/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
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDazCCAlOgAwIBAgIUB7PQvHZD6v8hfxeaDbU3hC0nGQQwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRTELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||||
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MDYxOTUzMDhaFw0yNjA0
|
||||||
|
MDYxOTUzMDhaMEUxCzAJBgNVBAYTAk5MMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||||
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||||
|
AQUAA4IBDwAwggEKAoIBAQCtYf8PP7QjRJOfK6zmfAZhSKwMowCSYijKeChxsgyn
|
||||||
|
hDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXesBBPhie+4KmtsykiI7QEHXVVrWHba
|
||||||
|
6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71Fme4ofJ2Plb7PnF53R4Tc3aTMdIW
|
||||||
|
HrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrldPHYbTGvBcDUil7qZ8hZ8ZxLMzu3
|
||||||
|
GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSyVz+fVgvLozNL9kV89hbZo7H/M37O
|
||||||
|
zmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZyXgXgdMdLAgMBAAGjUzBRMB0GA1Ud
|
||||||
|
DgQWBBQtGeiVTYjzWb2hTqJwipRVXU1LnzAfBgNVHSMEGDAWgBQtGeiVTYjzWb2h
|
||||||
|
TqJwipRVXU1LnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAc
|
||||||
|
1BacrGMlCd5nfYuvQfv0DdTVGc2FSqxPMRGrZKfjvjemgPMs0+DqUwCJiR6oEOGb
|
||||||
|
atOYoIBX9KGXSUKRYYc/N75bslwfV1CclNqd2mPxULfks/D8cAzf2mgw4kYSaDHs
|
||||||
|
tJkywBe9L6eIK4cQ5YJvutVNVKMYPi+9w+wKog/FafkamFfX/3SLCkGmV0Vv4g0q
|
||||||
|
Ro9KmTTQpJUvd63X8bONLs1t8p+HQfWmKlhuVn5+mncNdGREe8dbciXE5FKu8luN
|
||||||
|
dr/twoTZTPhmIHPmVEeNxS8hFSiu0iUPTO0HcCAODILGbtXbClA+1Z0ukiRfUya6
|
||||||
|
tgVuEk0c64L86qGP7Ply
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf
|
||||||
|
K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes
|
||||||
|
BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71
|
||||||
|
Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl
|
||||||
|
dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy
|
||||||
|
Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy
|
||||||
|
XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1
|
||||||
|
NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER
|
||||||
|
TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q
|
||||||
|
RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC
|
||||||
|
1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG
|
||||||
|
eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F
|
||||||
|
aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C
|
||||||
|
+DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz
|
||||||
|
OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl
|
||||||
|
v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB
|
||||||
|
orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs
|
||||||
|
eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s
|
||||||
|
qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F
|
||||||
|
8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC
|
||||||
|
L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC
|
||||||
|
DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E
|
||||||
|
9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv
|
||||||
|
9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45
|
||||||
|
5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3
|
||||||
|
xhhX7RsDOYMFKmn59DS1CQCZAA==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -38,6 +38,8 @@ dependencies = [
|
|||||||
"humanize",
|
"humanize",
|
||||||
"Pillow",
|
"Pillow",
|
||||||
"pillow-heif",
|
"pillow-heif",
|
||||||
|
"IP2Location",
|
||||||
|
"bleach"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1 +1,68 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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, 'r') 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 IOError as e:
|
||||||
|
raise IOError(f"Error reading environment file '{file_path}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
load_env()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,27 +1,82 @@
|
|||||||
import click
|
|
||||||
import uvloop
|
|
||||||
from aiohttp import web
|
|
||||||
import asyncio
|
|
||||||
from snek.app import Application
|
|
||||||
from IPython import start_ipython
|
|
||||||
import sqlite3
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import shutil
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import asyncio
|
||||||
|
import click
|
||||||
|
from aiohttp import web
|
||||||
|
from IPython import start_ipython
|
||||||
|
from snek.shell import Shell
|
||||||
|
from snek.app import Application
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def cli():
|
def cli():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.')
|
def export():
|
||||||
@click.option('--source',default=None, help='Database to initialize if not exists.')
|
|
||||||
def init(db_path,source):
|
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):
|
||||||
if source and pathlib.Path(source).exists():
|
if source and pathlib.Path(source).exists():
|
||||||
print(f"Copying {source} to {db_path}")
|
print(f"Copying {source} to {db_path}")
|
||||||
shutil.copy2(source,db_path)
|
shutil.copy2(source, db_path)
|
||||||
print("Database initialized.")
|
print("Database initialized.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if pathlib.Path(db_path).exists():
|
if pathlib.Path(db_path).exists():
|
||||||
return
|
return
|
||||||
print(f"Initializing database at {db_path}")
|
print(f"Initializing database at {db_path}")
|
||||||
@@ -33,25 +88,42 @@ def init(db_path,source):
|
|||||||
db.close()
|
db.close()
|
||||||
print("Database initialized.")
|
print("Database initialized.")
|
||||||
|
|
||||||
@cli.command()
|
|
||||||
@click.option('--port', default=8081, show_default=True, help='Port to run the application on')
|
|
||||||
@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')
|
|
||||||
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
|
||||||
def serve(port, host, db_path):
|
|
||||||
#init(db_path)
|
|
||||||
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
||||||
web.run_app(
|
|
||||||
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
|
|
||||||
)
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
@click.option(
|
||||||
|
"--port", default=8081, show_default=True, help="Port to run the application on"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--host",
|
||||||
|
default="0.0.0.0",
|
||||||
|
show_default=True,
|
||||||
|
help="Host to run the application on",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--db_path",
|
||||||
|
default="snek.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Database path for the application",
|
||||||
|
)
|
||||||
|
def serve(port, host, db_path):
|
||||||
|
# init(db_path)
|
||||||
|
# asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
|
web.run_app(Application(db_path=f"sqlite:///{db_path}"), port=port, host=host)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option(
|
||||||
|
"--db_path",
|
||||||
|
default="snek.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Database path for the application",
|
||||||
|
)
|
||||||
def shell(db_path):
|
def shell(db_path):
|
||||||
app = Application(db_path=f"sqlite:///{db_path}")
|
Shell(db_path).run()
|
||||||
start_ipython(argv=[], user_ns={'app': app})
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cli()
|
cli()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import ssl
|
||||||
import uuid
|
import uuid
|
||||||
|
import signal
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from snek import snode
|
|
||||||
|
from snek import snode
|
||||||
from snek.view.threads import ThreadsView
|
from snek.view.threads import ThreadsView
|
||||||
import json
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from ipaddress import ip_address
|
||||||
|
|
||||||
|
import IP2Location
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp_session import (
|
from aiohttp_session import (
|
||||||
get_session as session_get,
|
get_session as session_get,
|
||||||
@@ -21,46 +24,59 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
|||||||
from app.app import Application as BaseApplication
|
from app.app import Application as BaseApplication
|
||||||
from jinja2 import FileSystemLoader
|
from jinja2 import FileSystemLoader
|
||||||
|
|
||||||
from snek.sssh import start_ssh_server
|
|
||||||
from snek.docs.app import Application as DocsApplication
|
|
||||||
from snek.mapper import get_mappers
|
from snek.mapper import get_mappers
|
||||||
from snek.service import get_services
|
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 import http
|
||||||
from snek.system.cache import Cache
|
from snek.system.cache import Cache
|
||||||
from snek.system.markdown import MarkdownExtension
|
from snek.system.markdown import MarkdownExtension
|
||||||
from snek.system.middleware import auth_middleware, cors_middleware
|
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
||||||
from snek.system.profiler import profiler_handler
|
from snek.system.profiler import profiler_handler
|
||||||
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
|
from snek.system.template import (
|
||||||
|
EmojiExtension,
|
||||||
|
LinkifyExtension,
|
||||||
|
PythonExtension,
|
||||||
|
sanitize_html,
|
||||||
|
)
|
||||||
from snek.view.about import AboutHTMLView, AboutMDView
|
from snek.view.about import AboutHTMLView, AboutMDView
|
||||||
from snek.view.avatar import AvatarView
|
from snek.view.avatar import AvatarView
|
||||||
|
from snek.view.channel import ChannelAttachmentView, ChannelView
|
||||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||||
from snek.view.drive import DriveView
|
from snek.view.drive import DriveApiView, DriveView
|
||||||
from snek.view.drive import DriveApiView
|
|
||||||
from snek.view.index import IndexView
|
from snek.view.index import IndexView
|
||||||
from snek.view.login import LoginView
|
from snek.view.login import LoginView
|
||||||
from snek.view.logout import LogoutView
|
from snek.view.logout import LogoutView
|
||||||
|
from snek.view.push import PushView
|
||||||
from snek.view.register import RegisterView
|
from snek.view.register import RegisterView
|
||||||
from snek.view.rpc import RPCView
|
|
||||||
from snek.view.repository import RepositoryView
|
from snek.view.repository import RepositoryView
|
||||||
|
from snek.view.rpc import RPCView
|
||||||
from snek.view.search_user import SearchUserView
|
from snek.view.search_user import SearchUserView
|
||||||
from snek.view.settings.repositories import RepositoriesIndexView
|
from snek.view.container import ContainerView
|
||||||
from snek.view.settings.repositories import RepositoriesCreateView
|
from snek.view.settings.containers import (
|
||||||
from snek.view.settings.repositories import RepositoriesUpdateView
|
ContainersCreateView,
|
||||||
from snek.view.settings.repositories import RepositoriesDeleteView
|
ContainersDeleteView,
|
||||||
|
ContainersIndexView,
|
||||||
|
ContainersUpdateView,
|
||||||
|
)
|
||||||
from snek.view.settings.index import SettingsIndexView
|
from snek.view.settings.index import SettingsIndexView
|
||||||
from snek.view.settings.profile import SettingsProfileView
|
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.stats import StatsView
|
||||||
from snek.view.status import StatusView
|
from snek.view.status import StatusView
|
||||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||||
from snek.view.upload import UploadView
|
from snek.view.upload import UploadView
|
||||||
from snek.view.user import UserView
|
from snek.view.user import UserView
|
||||||
from snek.view.web import WebView
|
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.webdav import WebdavApplication
|
||||||
from snek.sgit import GitApplication
|
|
||||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||||
|
from snek.system.template import whitelist_attributes
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
@@ -70,6 +86,33 @@ async def session_middleware(request, handler):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def ip2location_middleware(request, handler):
|
||||||
|
response = await handler(request)
|
||||||
|
return response
|
||||||
|
ip = request.headers.get("X-Forwarded-For", request.remote)
|
||||||
|
ipaddress = ip_address(ip)
|
||||||
|
if ipaddress.is_private:
|
||||||
|
return response
|
||||||
|
if not request.app.session.get("uid"):
|
||||||
|
return response
|
||||||
|
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
|
||||||
|
if not user:
|
||||||
|
return response
|
||||||
|
location = request.app.ip2location.get(ip)
|
||||||
|
user["city"]
|
||||||
|
if user["city"] != location.city:
|
||||||
|
user["country_long"] = location.country
|
||||||
|
user["country_short"] = locaion.country_short
|
||||||
|
user["city"] = location.city
|
||||||
|
user["region"] = location.region
|
||||||
|
user["latitude"] = location.latitude
|
||||||
|
user["longitude"] = location.longitude
|
||||||
|
user["ip"] = ip
|
||||||
|
await request.app.services.user.update(user)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def trailing_slash_middleware(request, handler):
|
async def trailing_slash_middleware(request, handler):
|
||||||
if request.path and not request.path.endswith("/"):
|
if request.path and not request.path.endswith("/"):
|
||||||
@@ -79,16 +122,20 @@ async def trailing_slash_middleware(request, handler):
|
|||||||
|
|
||||||
|
|
||||||
class Application(BaseApplication):
|
class Application(BaseApplication):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
middlewares = [
|
middlewares = [
|
||||||
cors_middleware,
|
cors_middleware,
|
||||||
web.normalize_path_middleware(merge_slashes=True),
|
web.normalize_path_middleware(merge_slashes=True),
|
||||||
|
ip2location_middleware,
|
||||||
|
csp_middleware,
|
||||||
]
|
]
|
||||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||||
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
|
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
|
||||||
super().__init__(
|
super().__init__(
|
||||||
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs
|
middlewares=middlewares,
|
||||||
|
template_path=self.template_path,
|
||||||
|
client_max_size=1024 * 1024 * 1024 * 5 * args,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||||
self.tasks = asyncio.Queue()
|
self.tasks = asyncio.Queue()
|
||||||
@@ -98,9 +145,11 @@ class Application(BaseApplication):
|
|||||||
self.jinja2_env.add_extension(LinkifyExtension)
|
self.jinja2_env.add_extension(LinkifyExtension)
|
||||||
self.jinja2_env.add_extension(PythonExtension)
|
self.jinja2_env.add_extension(PythonExtension)
|
||||||
self.jinja2_env.add_extension(EmojiExtension)
|
self.jinja2_env.add_extension(EmojiExtension)
|
||||||
|
self.jinja2_env.filters["sanitize"] = sanitize_html
|
||||||
self.time_start = datetime.now()
|
self.time_start = datetime.now()
|
||||||
self.ssh_host = "0.0.0.0"
|
self.ssh_host = "0.0.0.0"
|
||||||
self.ssh_port = 2242
|
self.ssh_port = 2242
|
||||||
|
|
||||||
self.setup_router()
|
self.setup_router()
|
||||||
self.ssh_server = None
|
self.ssh_server = None
|
||||||
self.sync_service = None
|
self.sync_service = None
|
||||||
@@ -110,21 +159,24 @@ class Application(BaseApplication):
|
|||||||
self.mappers = get_mappers(app=self)
|
self.mappers = get_mappers(app=self)
|
||||||
self.broadcast_service = None
|
self.broadcast_service = None
|
||||||
self.user_availability_service_task = None
|
self.user_availability_service_task = None
|
||||||
|
base_path = pathlib.Path(__file__).parent
|
||||||
|
self.ip2location = IP2Location.IP2Location(
|
||||||
|
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||||
|
)
|
||||||
self.on_startup.append(self.prepare_asyncio)
|
self.on_startup.append(self.prepare_asyncio)
|
||||||
self.on_startup.append(self.start_user_availability_service)
|
self.on_startup.append(self.start_user_availability_service)
|
||||||
self.on_startup.append(self.start_ssh_server)
|
self.on_startup.append(self.start_ssh_server)
|
||||||
self.on_startup.append(self.prepare_database)
|
self.on_startup.append(self.prepare_database)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uptime_seconds(self):
|
def uptime_seconds(self):
|
||||||
return (datetime.now() - self.time_start).total_seconds()
|
return (datetime.now() - self.time_start).total_seconds()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uptime(self):
|
def uptime(self):
|
||||||
return self._format_uptime(self.uptime_seconds)
|
return self._format_uptime(self.uptime_seconds)
|
||||||
|
|
||||||
def _format_uptime(self,seconds):
|
def _format_uptime(self, seconds):
|
||||||
seconds = int(seconds)
|
seconds = int(seconds)
|
||||||
days, seconds = divmod(seconds, 86400)
|
days, seconds = divmod(seconds, 86400)
|
||||||
hours, seconds = divmod(seconds, 3600)
|
hours, seconds = divmod(seconds, 3600)
|
||||||
@@ -142,14 +194,16 @@ class Application(BaseApplication):
|
|||||||
|
|
||||||
return ", ".join(parts)
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
async def start_user_availability_service(self, app):
|
async def start_user_availability_service(self, app):
|
||||||
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
|
app.user_availability_service_task = asyncio.create_task(
|
||||||
|
app.services.socket.user_availability_service()
|
||||||
|
)
|
||||||
|
|
||||||
async def snode_sync(self, app):
|
async def snode_sync(self, app):
|
||||||
self.sync_service = asyncio.create_task(snode.sync_service(app))
|
self.sync_service = asyncio.create_task(snode.sync_service(app))
|
||||||
|
|
||||||
async def start_ssh_server(self, 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:
|
if app.ssh_server:
|
||||||
asyncio.create_task(app.ssh_server.wait_closed())
|
asyncio.create_task(app.ssh_server.wait_closed())
|
||||||
|
|
||||||
@@ -157,6 +211,10 @@ class Application(BaseApplication):
|
|||||||
# app.loop = asyncio.get_running_loop()
|
# app.loop = asyncio.get_running_loop()
|
||||||
app.executor = ThreadPoolExecutor(max_workers=200)
|
app.executor = ThreadPoolExecutor(max_workers=200)
|
||||||
app.loop.set_default_executor(self.executor)
|
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):
|
async def create_task(self, task):
|
||||||
await self.tasks.put(task)
|
await self.tasks.put(task)
|
||||||
@@ -198,6 +256,7 @@ class Application(BaseApplication):
|
|||||||
show_index=True,
|
show_index=True,
|
||||||
)
|
)
|
||||||
self.router.add_view("/profiler.html", profiler_handler)
|
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.html", AboutHTMLView)
|
||||||
self.router.add_view("/about.md", AboutMDView)
|
self.router.add_view("/about.md", AboutMDView)
|
||||||
self.router.add_view("/logout.json", LogoutView)
|
self.router.add_view("/logout.json", LogoutView)
|
||||||
@@ -208,6 +267,7 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/settings/index.html", SettingsIndexView)
|
self.router.add_view("/settings/index.html", SettingsIndexView)
|
||||||
self.router.add_view("/settings/profile.html", SettingsProfileView)
|
self.router.add_view("/settings/profile.html", SettingsProfileView)
|
||||||
self.router.add_view("/settings/profile.json", 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("/web.html", WebView)
|
||||||
self.router.add_view("/login.html", LoginView)
|
self.router.add_view("/login.html", LoginView)
|
||||||
self.router.add_view("/login.json", LoginView)
|
self.router.add_view("/login.json", LoginView)
|
||||||
@@ -223,8 +283,12 @@ class Application(BaseApplication):
|
|||||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||||
self.router.add_get("/rpc.ws", RPCView)
|
self.router.add_get("/rpc.ws", RPCView)
|
||||||
self.router.add_get("/c/{channel:.*}", ChannelView)
|
self.router.add_get("/c/{channel:.*}", ChannelView)
|
||||||
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
|
self.router.add_view(
|
||||||
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
|
"/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("/channel/{channel}.html", WebView)
|
||||||
self.router.add_view("/threads.html", ThreadsView)
|
self.router.add_view("/threads.html", ThreadsView)
|
||||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||||
@@ -235,26 +299,40 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/stats.json", StatsView)
|
self.router.add_view("/stats.json", StatsView)
|
||||||
self.router.add_view("/user/{user}.html", UserView)
|
self.router.add_view("/user/{user}.html", UserView)
|
||||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
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/index.html", RepositoriesIndexView)
|
||||||
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
|
self.router.add_view(
|
||||||
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
|
"/settings/repositories/create.html", RepositoriesCreateView
|
||||||
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
|
)
|
||||||
|
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/index.html", ContainersIndexView)
|
||||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||||
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
|
self.router.add_view(
|
||||||
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
|
"/settings/containers/container/{uid}/update.html", ContainersUpdateView
|
||||||
|
)
|
||||||
|
self.router.add_view(
|
||||||
|
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
|
||||||
|
)
|
||||||
self.webdav = WebdavApplication(self)
|
self.webdav = WebdavApplication(self)
|
||||||
self.git = GitApplication(self)
|
self.git = GitApplication(self)
|
||||||
self.add_subapp("/webdav", self.webdav)
|
self.add_subapp("/webdav", self.webdav)
|
||||||
self.add_subapp("/git",self.git)
|
self.add_subapp("/git", self.git)
|
||||||
|
|
||||||
#self.router.add_get("/{file_path:.*}", self.static_handler)
|
# self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||||
|
|
||||||
async def handle_test(self, request):
|
async def handle_test(self, request):
|
||||||
|
|
||||||
return await self.render_template(
|
return await whitelist_attributes(
|
||||||
"test.html", request, context={"name": "retoor"}
|
self.render_template("test.html", request, context={"name": "retoor"})
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_http_get(self, request: web.Request):
|
async def handle_http_get(self, request: web.Request):
|
||||||
@@ -279,9 +357,8 @@ class Application(BaseApplication):
|
|||||||
async for subscribed_channel in self.services.channel_member.find(
|
async for subscribed_channel in self.services.channel_member.find(
|
||||||
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
||||||
):
|
):
|
||||||
|
|
||||||
parent_object = await subscribed_channel.get_channel()
|
parent_object = await subscribed_channel.get_channel()
|
||||||
|
|
||||||
item = {}
|
item = {}
|
||||||
other_user = await self.services.channel_member.get_other_dm_user(
|
other_user = await self.services.channel_member.get_other_dm_user(
|
||||||
subscribed_channel["channel_uid"], request.session.get("uid")
|
subscribed_channel["channel_uid"], request.session.get("uid")
|
||||||
@@ -327,11 +404,12 @@ class Application(BaseApplication):
|
|||||||
|
|
||||||
self.jinja2_env.loader = self.original_loader
|
self.jinja2_env.loader = self.original_loader
|
||||||
|
|
||||||
|
# rendered.text = whitelist_attributes(rendered.text)
|
||||||
|
# rendered.headers['Content-Lenght'] = len(rendered.text)
|
||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
async def static_handler(self, request):
|
async def static_handler(self, request):
|
||||||
file_name = request.match_info.get('filename', '')
|
file_name = request.match_info.get("filename", "")
|
||||||
|
|
||||||
paths = []
|
paths = []
|
||||||
|
|
||||||
@@ -340,12 +418,12 @@ class Application(BaseApplication):
|
|||||||
user_static_path = await self.services.user.get_static_path(uid)
|
user_static_path = await self.services.user.get_static_path(uid)
|
||||||
if user_static_path:
|
if user_static_path:
|
||||||
paths.append(user_static_path)
|
paths.append(user_static_path)
|
||||||
|
|
||||||
for admin_uid in self.services.user.get_admin_uids():
|
for admin_uid in self.services.user.get_admin_uids():
|
||||||
user_static_path = await self.services.user.get_static_path(admin_uid)
|
user_static_path = await self.services.user.get_static_path(admin_uid)
|
||||||
if user_static_path:
|
if user_static_path:
|
||||||
paths.append(user_static_path)
|
paths.append(user_static_path)
|
||||||
|
|
||||||
paths.append(self.static_path)
|
paths.append(self.static_path)
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
@@ -365,7 +443,6 @@ class Application(BaseApplication):
|
|||||||
if user_template_path:
|
if user_template_path:
|
||||||
template_paths.append(user_template_path)
|
template_paths.append(user_template_path)
|
||||||
|
|
||||||
|
|
||||||
template_paths.append(self.template_path)
|
template_paths.append(self.template_path)
|
||||||
return FileSystemLoader(template_paths)
|
return FileSystemLoader(template_paths)
|
||||||
|
|
||||||
@@ -374,7 +451,9 @@ app = Application(db_path="sqlite:///snek.db")
|
|||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
await web._run_app(app, port=8081, host="0.0.0.0")
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
|
ssl_context.load_cert_chain("cert.pem", "key.pem")
|
||||||
|
await web._run_app(app, port=8081, host="0.0.0.0", ssl_context=ssl_context)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
class LoadBalancer:
|
class LoadBalancer:
|
||||||
def __init__(self, backend_ports):
|
def __init__(self, backend_ports):
|
||||||
self.backend_ports = backend_ports
|
self.backend_ports = backend_ports
|
||||||
@@ -8,27 +9,29 @@ class LoadBalancer:
|
|||||||
self.client_counts = [0] * len(backend_ports)
|
self.client_counts = [0] * len(backend_ports)
|
||||||
self.lock = asyncio.Lock()
|
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):
|
for x in range(workers):
|
||||||
port += 1
|
port += 1
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
sys.executable,
|
sys.executable,
|
||||||
sys.argv[0],
|
sys.argv[0],
|
||||||
'backend',
|
"backend",
|
||||||
str(port),
|
str(port),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
port += 1
|
port += 1
|
||||||
self.backend_processes.append(process)
|
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 def handle_client(self, reader, writer):
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
min_clients = min(self.client_counts)
|
min_clients = min(self.client_counts)
|
||||||
server_index = self.client_counts.index(min_clients)
|
server_index = self.client_counts.index(min_clients)
|
||||||
self.client_counts[server_index] += 1
|
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:
|
try:
|
||||||
backend_reader, backend_writer = await asyncio.open_connection(*backend)
|
backend_reader, backend_writer = await asyncio.open_connection(*backend)
|
||||||
|
|
||||||
@@ -62,10 +65,10 @@ class LoadBalancer:
|
|||||||
for i, count in enumerate(self.client_counts):
|
for i, count in enumerate(self.client_counts):
|
||||||
print(f"Server {self.backend_ports[i]}: {count} clients")
|
print(f"Server {self.backend_ports[i]}: {count} clients")
|
||||||
|
|
||||||
async def start(self, host='0.0.0.0', port=8081,workers=5):
|
async def start(self, host="0.0.0.0", port=8081, workers=5):
|
||||||
await self.start_backend_servers(port,workers)
|
await self.start_backend_servers(port, workers)
|
||||||
server = await asyncio.start_server(self.handle_client, host, port)
|
server = await asyncio.start_server(self.handle_client, host, port)
|
||||||
monitor_task = asyncio.create_task(self.monitor())
|
asyncio.create_task(self.monitor())
|
||||||
|
|
||||||
# Handle shutdown gracefully
|
# Handle shutdown gracefully
|
||||||
try:
|
try:
|
||||||
@@ -80,6 +83,7 @@ class LoadBalancer:
|
|||||||
await asyncio.gather(*(p.wait() for p in self.backend_processes))
|
await asyncio.gather(*(p.wait() for p in self.backend_processes))
|
||||||
print("Backend processes terminated.")
|
print("Backend processes terminated.")
|
||||||
|
|
||||||
|
|
||||||
async def backend_echo_server(port):
|
async def backend_echo_server(port):
|
||||||
async def handle_echo(reader, writer):
|
async def handle_echo(reader, writer):
|
||||||
try:
|
try:
|
||||||
@@ -94,10 +98,11 @@ async def backend_echo_server(port):
|
|||||||
finally:
|
finally:
|
||||||
writer.close()
|
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}")
|
print(f"Backend echo server running on port {port}")
|
||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
backend_ports = [8001, 8003, 8005, 8006]
|
backend_ports = [8001, 8003, 8005, 8006]
|
||||||
# Launch backend echo servers
|
# Launch backend echo servers
|
||||||
@@ -105,19 +110,20 @@ async def main():
|
|||||||
lb = LoadBalancer(backend_ports)
|
lb = LoadBalancer(backend_ports)
|
||||||
await lb.start()
|
await lb.start()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
if sys.argv[1] == 'backend':
|
if sys.argv[1] == "backend":
|
||||||
port = int(sys.argv[2])
|
port = int(sys.argv[2])
|
||||||
from snek.app import Application
|
from snek.app import Application
|
||||||
|
|
||||||
snek = Application(port=port)
|
snek = Application(port=port)
|
||||||
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':
|
elif sys.argv[1] == "sync":
|
||||||
from snek.sync import app
|
|
||||||
web.run_app(snek, port=port, host='127.0.0.1')
|
web.run_app(snek, port=port, host="127.0.0.1")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import functools
|
import functools
|
||||||
|
|
||||||
from snek.mapper.channel import ChannelMapper
|
from snek.mapper.channel import ChannelMapper
|
||||||
|
from snek.mapper.channel_attachment import ChannelAttachmentMapper
|
||||||
from snek.mapper.channel_member import ChannelMemberMapper
|
from snek.mapper.channel_member import ChannelMemberMapper
|
||||||
from snek.mapper.channel_message import ChannelMessageMapper
|
from snek.mapper.channel_message import ChannelMessageMapper
|
||||||
|
from snek.mapper.container import ContainerMapper
|
||||||
from snek.mapper.drive import DriveMapper
|
from snek.mapper.drive import DriveMapper
|
||||||
from snek.mapper.drive_item import DriveItemMapper
|
from snek.mapper.drive_item import DriveItemMapper
|
||||||
from snek.mapper.notification import NotificationMapper
|
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 import UserMapper
|
||||||
from snek.mapper.user_property import UserPropertyMapper
|
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
|
from snek.system.object import Object
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
|
|
||||||
def get_mappers(app=None):
|
def get_mappers(app=None):
|
||||||
return Object(
|
return Object(
|
||||||
**{
|
**{
|
||||||
@@ -30,6 +30,7 @@ def get_mappers(app=None):
|
|||||||
"repository": RepositoryMapper(app=app),
|
"repository": RepositoryMapper(app=app),
|
||||||
"channel_attachment": ChannelAttachmentMapper(app=app),
|
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||||
"container": ContainerMapper(app=app),
|
"container": ContainerMapper(app=app),
|
||||||
|
"push": PushMapper(app=app),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from snek.model.container import Container
|
from snek.model.container import Container
|
||||||
from snek.system.mapper import BaseMapper
|
from snek.system.mapper import BaseMapper
|
||||||
|
|
||||||
|
|
||||||
class ContainerMapper(BaseMapper):
|
class ContainerMapper(BaseMapper):
|
||||||
model_class = Container
|
model_class = Container
|
||||||
table_name = "container"
|
table_name = "container"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from snek.model.push_registration import PushRegistrationModel
|
||||||
|
from snek.system.mapper import BaseMapper
|
||||||
|
|
||||||
|
|
||||||
|
class PushMapper(BaseMapper):
|
||||||
|
model_class = PushRegistrationModel
|
||||||
|
table_name = "push_registration"
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import functools
|
import functools
|
||||||
|
|
||||||
from snek.model.channel import ChannelModel
|
from snek.model.channel import ChannelModel
|
||||||
|
from snek.model.channel_attachment import ChannelAttachmentModel
|
||||||
from snek.model.channel_member import ChannelMemberModel
|
from snek.model.channel_member import ChannelMemberModel
|
||||||
|
|
||||||
# from snek.model.channel_message import ChannelMessageModel
|
# from snek.model.channel_message import ChannelMessageModel
|
||||||
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 import DriveModel
|
||||||
from snek.model.drive_item import DriveItemModel
|
from snek.model.drive_item import DriveItemModel
|
||||||
from snek.model.notification import NotificationModel
|
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 import UserModel
|
||||||
from snek.model.user_property import UserPropertyModel
|
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
|
from snek.system.object import Object
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def get_models():
|
|||||||
"repository": RepositoryModel,
|
"repository": RepositoryModel,
|
||||||
"channel_attachment": ChannelAttachmentModel,
|
"channel_attachment": ChannelAttachmentModel,
|
||||||
"container": Container,
|
"container": Container,
|
||||||
|
"push_registration": PushRegistrationModel,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from snek.system.model import BaseModel
|
|
||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +10,6 @@ class ChannelAttachmentModel(BaseModel):
|
|||||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
mime_type = ModelField(name="type", required=True, kind=str)
|
mime_type = ModelField(name="type", required=True, kind=str)
|
||||||
relative_url = ModelField(name="relative_url", 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,6 +1,8 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from snek.model.user import UserModel
|
from snek.model.user import UserModel
|
||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
from datetime import datetime,timezone
|
|
||||||
|
|
||||||
class ChannelMessageModel(BaseModel):
|
class ChannelMessageModel(BaseModel):
|
||||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||||
@@ -10,7 +12,11 @@ class ChannelMessageModel(BaseModel):
|
|||||||
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
|
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
|
||||||
|
|
||||||
def get_seconds_since_last_update(self):
|
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:
|
async def get_user(self) -> UserModel:
|
||||||
return await self.app.services.user.get(uid=self["user_uid"])
|
return await self.app.services.user.get(uid=self["user_uid"])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
class Container(BaseModel):
|
class Container(BaseModel):
|
||||||
id = ModelField(name="id", required=True, kind=str)
|
id = ModelField(name="id", required=True, kind=str)
|
||||||
name = ModelField(name="name", required=True, kind=str)
|
name = ModelField(name="name", required=True, kind=str)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ class DriveItemModel(BaseModel):
|
|||||||
path = ModelField(name="path", required=True, kind=str)
|
path = ModelField(name="path", required=True, kind=str)
|
||||||
file_type = ModelField(name="file_type", required=True, kind=str)
|
file_type = ModelField(name="file_type", required=True, kind=str)
|
||||||
file_size = ModelField(name="file_size", required=True, kind=int)
|
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
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
|
class PushRegistrationModel(BaseModel):
|
||||||
|
user_uid = ModelField(name="user_uid", required=True)
|
||||||
|
endpoint = ModelField(name="endpoint", required=True)
|
||||||
|
key_auth = ModelField(name="key_auth", required=True)
|
||||||
|
key_p256dh = ModelField(name="key_p256dh", required=True)
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
from snek.model.user import UserModel
|
|
||||||
from snek.system.model import BaseModel, ModelField
|
from snek.system.model import BaseModel, ModelField
|
||||||
|
|
||||||
|
|
||||||
class RepositoryModel(BaseModel):
|
class RepositoryModel(BaseModel):
|
||||||
|
|
||||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
|
|
||||||
name = ModelField(name="name", required=True, kind=str)
|
name = ModelField(name="name", required=True, kind=str)
|
||||||
|
|
||||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ class UserModel(BaseModel):
|
|||||||
|
|
||||||
is_admin = ModelField(name="is_admin", required=False, kind=bool)
|
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):
|
async def get_property(self, name):
|
||||||
prop = await self.app.services.user_property.find_one(
|
prop = await self.app.services.user_property.find_one(
|
||||||
user_uid=self["uid"], name=name
|
user_uid=self["uid"], name=name
|
||||||
|
|||||||
@@ -1,51 +1,54 @@
|
|||||||
import snek.serpentarium
|
import time
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
|
||||||
|
import snek.serpentarium
|
||||||
|
|
||||||
durations = []
|
durations = []
|
||||||
|
|
||||||
|
|
||||||
def task1():
|
def task1():
|
||||||
global durations
|
global durations
|
||||||
client = snek.serpentarium.DatasetWrapper()
|
client = snek.serpentarium.DatasetWrapper()
|
||||||
|
|
||||||
start=time.time()
|
start = time.time()
|
||||||
for x in range(1500):
|
for x in range(1500):
|
||||||
|
|
||||||
client['a'].delete()
|
client["a"].delete()
|
||||||
client['a'].insert({"foo": x})
|
client["a"].insert({"foo": x})
|
||||||
client['a'].find(foo=x)
|
client["a"].find(foo=x)
|
||||||
client['a'].find_one(foo=x)
|
client["a"].find_one(foo=x)
|
||||||
client['a'].count()
|
client["a"].count()
|
||||||
#print(client['a'].find(foo=x) )
|
# print(client['a'].find(foo=x) )
|
||||||
#print(client['a'].find_one(foo=x) )
|
# print(client['a'].find_one(foo=x) )
|
||||||
#print(client['a'].count())
|
# print(client['a'].count())
|
||||||
client.close()
|
client.close()
|
||||||
duration1 = f"{time.time()-start}"
|
duration1 = f"{time.time()-start}"
|
||||||
durations.append(duration1)
|
durations.append(duration1)
|
||||||
print(durations)
|
print(durations)
|
||||||
|
|
||||||
|
|
||||||
with ProcessPoolExecutor(max_workers=4) as executor:
|
with ProcessPoolExecutor(max_workers=4) as executor:
|
||||||
tasks = [executor.submit(task1),
|
tasks = [
|
||||||
|
executor.submit(task1),
|
||||||
|
executor.submit(task1),
|
||||||
executor.submit(task1),
|
executor.submit(task1),
|
||||||
executor.submit(task1),
|
executor.submit(task1),
|
||||||
executor.submit(task1)
|
|
||||||
]
|
]
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.result()
|
task.result()
|
||||||
|
|
||||||
|
|
||||||
import dataset
|
import dataset
|
||||||
|
|
||||||
client = dataset.connect("sqlite:///snek.db")
|
client = dataset.connect("sqlite:///snek.db")
|
||||||
start=time.time()
|
start = time.time()
|
||||||
for x in range(1500):
|
for x in range(1500):
|
||||||
|
|
||||||
client['a'].delete()
|
client["a"].delete()
|
||||||
client['a'].insert({"foo": x})
|
client["a"].insert({"foo": x})
|
||||||
print([dict(row) for row in client['a'].find(foo=x)])
|
print([dict(row) for row in client["a"].find(foo=x)])
|
||||||
print(dict(client['a'].find_one(foo=x) ))
|
print(dict(client["a"].find_one(foo=x)))
|
||||||
print(client['a'].count())
|
print(client["a"].count())
|
||||||
duration2 = f"{time.time()-start}"
|
duration2 = f"{time.time()-start}"
|
||||||
|
|
||||||
print(duration1,duration2)
|
print(duration1, duration2)
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import functools
|
import functools
|
||||||
|
|
||||||
from snek.service.channel import ChannelService
|
from snek.service.channel import ChannelService
|
||||||
|
from snek.service.channel_attachment import ChannelAttachmentService
|
||||||
from snek.service.channel_member import ChannelMemberService
|
from snek.service.channel_member import ChannelMemberService
|
||||||
from snek.service.channel_message import ChannelMessageService
|
from snek.service.channel_message import ChannelMessageService
|
||||||
from snek.service.chat import ChatService
|
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 import DriveService
|
||||||
from snek.service.drive_item import DriveItemService
|
from snek.service.drive_item import DriveItemService
|
||||||
from snek.service.notification import NotificationService
|
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.socket import SocketService
|
||||||
from snek.service.user import UserService
|
from snek.service.user import UserService
|
||||||
from snek.service.user_property import UserPropertyService
|
from snek.service.user_property import UserPropertyService
|
||||||
from snek.service.util import UtilService
|
from snek.service.util import UtilService
|
||||||
from snek.service.repository import RepositoryService
|
|
||||||
from snek.service.channel_attachment import ChannelAttachmentService
|
|
||||||
from snek.service.container import ContainerService
|
|
||||||
from snek.system.object import Object
|
from snek.system.object import Object
|
||||||
from snek.service.db import DBService
|
from snek.service.statistics import StatisticsService
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
def get_services(app):
|
def get_services(app):
|
||||||
@@ -36,6 +38,8 @@ def get_services(app):
|
|||||||
"db": DBService(app=app),
|
"db": DBService(app=app),
|
||||||
"channel_attachment": ChannelAttachmentService(app=app),
|
"channel_attachment": ChannelAttachmentService(app=app),
|
||||||
"container": ContainerService(app=app),
|
"container": ContainerService(app=app),
|
||||||
|
"push": PushService(app=app),
|
||||||
|
"statistics": StatisticsService(app=app),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
|
import pathlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
class ChannelService(BaseService):
|
class ChannelService(BaseService):
|
||||||
mapper_name = "channel"
|
mapper_name = "channel"
|
||||||
|
|
||||||
async def get_attachment_folder(self, channel_uid,ensure=False):
|
async def get_home_folder(self, channel_uid):
|
||||||
|
folder = pathlib.Path(f"./drive/{channel_uid}/container/home")
|
||||||
|
if not folder.exists():
|
||||||
|
try:
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return folder
|
||||||
|
|
||||||
|
async def get_attachment_folder(self, channel_uid, ensure=False):
|
||||||
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
|
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
|
||||||
if ensure:
|
if ensure:
|
||||||
path.mkdir(
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
parents=True, exist_ok=True
|
|
||||||
)
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
async def get(self, uid=None, **kwargs):
|
async def get(self, uid=None, **kwargs):
|
||||||
@@ -56,6 +63,7 @@ class ChannelService(BaseService):
|
|||||||
model["is_private"] = is_private
|
model["is_private"] = is_private
|
||||||
model["is_listed"] = is_listed
|
model["is_listed"] = is_listed
|
||||||
if await self.save(model):
|
if await self.save(model):
|
||||||
|
await self.services.container.create(model["uid"])
|
||||||
return model
|
return model
|
||||||
raise Exception(f"Failed to create channel: {model.errors}.")
|
raise Exception(f"Failed to create channel: {model.errors}.")
|
||||||
|
|
||||||
@@ -68,7 +76,10 @@ class ChannelService(BaseService):
|
|||||||
return channel
|
return channel
|
||||||
|
|
||||||
async def get_recent_users(self, channel_uid):
|
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
|
yield user
|
||||||
|
|
||||||
async def get_users(self, channel_uid):
|
async def get_users(self, channel_uid):
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
from snek.system.service import BaseService
|
|
||||||
import urllib.parse
|
|
||||||
import pathlib
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import uuid
|
|
||||||
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
|
|
||||||
class ChannelAttachmentService(BaseService):
|
class ChannelAttachmentService(BaseService):
|
||||||
mapper_name="channel_attachment"
|
mapper_name = "channel_attachment"
|
||||||
|
|
||||||
async def create_file(self, channel_uid, user_uid, name):
|
async def create_file(self, channel_uid, user_uid, name):
|
||||||
attachment = await self.new()
|
attachment = await self.new()
|
||||||
attachment["channel_uid"] = channel_uid
|
attachment["channel_uid"] = channel_uid
|
||||||
attachment['user_uid'] = user_uid
|
attachment["user_uid"] = user_uid
|
||||||
attachment["name"] = name
|
attachment["name"] = name
|
||||||
attachment["mime_type"] = mimetypes.guess_type(name)[0]
|
attachment["mime_type"] = mimetypes.guess_type(name)[0]
|
||||||
attachment['resource_type'] = "file"
|
attachment["resource_type"] = "file"
|
||||||
real_file_name = f"{attachment['uid']}-{name}"
|
real_file_name = f"{attachment['uid']}-{name}"
|
||||||
attachment["relative_url"] = (f"{attachment['uid']}-{name}")
|
attachment["relative_url"] = f"{attachment['uid']}-{name}"
|
||||||
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)
|
attachment_folder = await self.services.channel.get_attachment_folder(
|
||||||
|
channel_uid
|
||||||
|
)
|
||||||
attachment_path = attachment_folder.joinpath(real_file_name)
|
attachment_path = attachment_folder.joinpath(real_file_name)
|
||||||
attachment["path"] = str(attachment_path)
|
attachment["path"] = str(attachment_path)
|
||||||
if await self.save(attachment):
|
if await self.save(attachment):
|
||||||
return attachment
|
return attachment
|
||||||
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
|
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
|
from snek.system.template import whitelist_attributes
|
||||||
|
|
||||||
|
|
||||||
class ChannelMessageService(BaseService):
|
class ChannelMessageService(BaseService):
|
||||||
mapper_name = "channel_message"
|
mapper_name = "channel_message"
|
||||||
|
|
||||||
|
async def maintenance(self):
|
||||||
|
async for message in self.find():
|
||||||
|
updated_at = message["updated_at"]
|
||||||
|
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"])
|
||||||
|
|
||||||
|
|
||||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
|
|
||||||
model["channel_uid"] = channel_uid
|
model["channel_uid"] = channel_uid
|
||||||
model["user_uid"] = user_uid
|
model["user_uid"] = user_uid
|
||||||
model["message"] = message
|
model["message"] = message
|
||||||
model['is_final'] = is_final
|
model["is_final"] = is_final
|
||||||
|
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
@@ -28,6 +46,7 @@ class ChannelMessageService(BaseService):
|
|||||||
try:
|
try:
|
||||||
template = self.app.jinja2_env.get_template("message.html")
|
template = self.app.jinja2_env.get_template("message.html")
|
||||||
model["html"] = template.render(**context)
|
model["html"] = template.render(**context)
|
||||||
|
model["html"] = whitelist_attributes(model["html"])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex, flush=True)
|
print(ex, flush=True)
|
||||||
|
|
||||||
@@ -54,7 +73,7 @@ class ChannelMessageService(BaseService):
|
|||||||
async def save(self, model):
|
async def save(self, model):
|
||||||
context = {}
|
context = {}
|
||||||
context.update(model.record)
|
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(
|
context.update(
|
||||||
{
|
{
|
||||||
"user_uid": user["uid"],
|
"user_uid": user["uid"],
|
||||||
@@ -65,6 +84,7 @@ class ChannelMessageService(BaseService):
|
|||||||
)
|
)
|
||||||
template = self.app.jinja2_env.get_template("message.html")
|
template = self.app.jinja2_env.get_template("message.html")
|
||||||
model["html"] = template.render(**context)
|
model["html"] = template.render(**context)
|
||||||
|
model["html"] = whitelist_attributes(model["html"])
|
||||||
return await super().save(model)
|
return await super().save(model)
|
||||||
|
|
||||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ class ChatService(BaseService):
|
|||||||
channel["last_message_on"] = now()
|
channel["last_message_on"] = now()
|
||||||
await self.services.channel.save(channel)
|
await self.services.channel.save(channel)
|
||||||
await self.services.socket.broadcast(
|
await self.services.socket.broadcast(
|
||||||
channel['uid'],
|
channel["uid"],
|
||||||
{
|
{
|
||||||
"message": channel_message["message"],
|
"message": channel_message["message"],
|
||||||
"html": channel_message["html"],
|
"html": channel_message["html"],
|
||||||
"user_uid": user['uid'],
|
"user_uid": user["uid"],
|
||||||
"color": user["color"],
|
"color": user["color"],
|
||||||
"channel_uid": channel['uid'],
|
"channel_uid": channel["uid"],
|
||||||
"created_at": channel_message["created_at"],
|
"created_at": channel_message["created_at"],
|
||||||
"updated_at": channel_message["updated_at"],
|
"updated_at": channel_message["updated_at"],
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
@@ -28,14 +28,16 @@ class ChatService(BaseService):
|
|||||||
"is_final": channel_message["is_final"],
|
"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):
|
async def send(self, user_uid, channel_uid, message, is_final=True):
|
||||||
channel = await self.services.channel.get(uid=channel_uid)
|
channel = await self.services.channel.get(uid=channel_uid)
|
||||||
if not channel:
|
if not channel:
|
||||||
raise Exception("Channel not found.")
|
raise Exception("Channel not found.")
|
||||||
channel_message = await self.services.channel_message.create(
|
channel_message = await self.services.channel_message.create(
|
||||||
channel_uid, user_uid, message,is_final
|
channel_uid, user_uid, message, is_final
|
||||||
)
|
)
|
||||||
channel_message_uid = channel_message["uid"]
|
channel_message_uid = channel_message["uid"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,100 @@
|
|||||||
|
from snek.system.docker import ComposeFileManager
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
|
|
||||||
class ContainerService(BaseService):
|
class ContainerService(BaseService):
|
||||||
mapper_name = "container"
|
mapper_name = "container"
|
||||||
|
|
||||||
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False):
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.compose_path = "snek-container-compose.yml"
|
||||||
|
self.compose = ComposeFileManager(self.compose_path,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 not name in self.event_listeners:
|
||||||
|
self.event_listeners[name] = {}
|
||||||
|
if not event in self.event_listeners[name]:
|
||||||
|
self.event_listeners[name][event] = []
|
||||||
|
self.event_listeners[name][event].append(event_handler)
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
model["id"] = id
|
model["id"] = id
|
||||||
model["name"] = name
|
model["name"] = name
|
||||||
@@ -19,11 +110,3 @@ class ContainerService(BaseService):
|
|||||||
return model
|
return model
|
||||||
raise Exception(f"Failed to create container: {model.errors}")
|
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,23 +1,21 @@
|
|||||||
|
import dataset
|
||||||
|
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
import dataset
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class DBService(BaseService):
|
class DBService(BaseService):
|
||||||
|
|
||||||
async def get_db(self, user_uid):
|
async def get_db(self, user_uid):
|
||||||
|
|
||||||
home_folder = await self.app.services.user.get_home_folder(user_uid)
|
home_folder = await self.app.services.user.get_home_folder(user_uid)
|
||||||
home_folder.mkdir(parents=True, exist_ok=True)
|
home_folder.mkdir(parents=True, exist_ok=True)
|
||||||
db_path = home_folder.joinpath("snek/user.db")
|
db_path = home_folder.joinpath("snek/user.db")
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
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):
|
async def insert(self, user_uid, table_name, values):
|
||||||
db = await self.get_db(user_uid)
|
db = await self.get_db(user_uid)
|
||||||
return db[table_name].insert(values)
|
return db[table_name].insert(values)
|
||||||
|
|
||||||
|
|
||||||
async def update(self, user_uid, table_name, values, filters):
|
async def update(self, user_uid, table_name, values, filters):
|
||||||
db = await self.get_db(user_uid)
|
db = await self.get_db(user_uid)
|
||||||
@@ -33,7 +31,7 @@ class DBService(BaseService):
|
|||||||
|
|
||||||
async def find(self, user_uid, table_name, kwargs):
|
async def find(self, user_uid, table_name, kwargs):
|
||||||
db = await self.get_db(user_uid)
|
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)]
|
return [dict(row) for row in db[table_name].find(**kwargs)]
|
||||||
|
|
||||||
async def get(self, user_uid, table_name, filters):
|
async def get(self, user_uid, table_name, filters):
|
||||||
@@ -45,14 +43,13 @@ class DBService(BaseService):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def delete(self, user_uid, table_name, filters):
|
async def delete(self, user_uid, table_name, filters):
|
||||||
db = await self.get_db(user_uid)
|
db = await self.get_db(user_uid)
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
return db[table_name].delete(**filters)
|
return db[table_name].delete(**filters)
|
||||||
|
|
||||||
async def query(self, sql,values):
|
async def query(self, sql, values):
|
||||||
db = await self.app.db
|
db = await self.app.db
|
||||||
return [dict(row) for row in db.query(sql, values or {})]
|
return [dict(row) for row in db.query(sql, values or {})]
|
||||||
|
|
||||||
@@ -62,8 +59,6 @@ class DBService(BaseService):
|
|||||||
filters = {}
|
filters = {}
|
||||||
return bool(db[table_name].find_one(**filters))
|
return bool(db[table_name].find_one(**filters))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def count(self, user_uid, table_name, filters):
|
async def count(self, user_uid, table_name, filters):
|
||||||
db = await self.get_db(user_uid)
|
db = await self.get_db(user_uid)
|
||||||
if not filters:
|
if not filters:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from snek.system.markdown import strip_markdown
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ class NotificationService(BaseService):
|
|||||||
channel_message = await self.services.channel_message.get(
|
channel_message = await self.services.channel_message.get(
|
||||||
uid=channel_message_uid
|
uid=channel_message_uid
|
||||||
)
|
)
|
||||||
|
if not channel_message["is_final"]:
|
||||||
|
return
|
||||||
user = await self.services.user.get(uid=channel_message["user_uid"])
|
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||||
self.app.db.begin()
|
self.app.db.begin()
|
||||||
async for channel_member in self.services.channel_member.find(
|
async for channel_member in self.services.channel_member.find(
|
||||||
@@ -62,4 +65,20 @@ class NotificationService(BaseService):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise Exception(f"Failed to create notification: {model.errors}.")
|
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()
|
self.app.db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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,13 +1,17 @@
|
|||||||
from snek.system.service import BaseService
|
import asyncio
|
||||||
import asyncio
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from snek.system.service import BaseService
|
||||||
|
|
||||||
|
|
||||||
class RepositoryService(BaseService):
|
class RepositoryService(BaseService):
|
||||||
mapper_name = "repository"
|
mapper_name = "repository"
|
||||||
|
|
||||||
async def delete(self, user_uid, name):
|
async def delete(self, user_uid, name):
|
||||||
loop = asyncio.get_event_loop()
|
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:
|
try:
|
||||||
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -15,7 +19,6 @@ class RepositoryService(BaseService):
|
|||||||
|
|
||||||
await super().delete(user_uid=user_uid, name=name)
|
await super().delete(user_uid=user_uid, name=name)
|
||||||
|
|
||||||
|
|
||||||
async def exists(self, user_uid, name, **kwargs):
|
async def exists(self, user_uid, name, **kwargs):
|
||||||
kwargs["user_uid"] = user_uid
|
kwargs["user_uid"] = user_uid
|
||||||
kwargs["name"] = name
|
kwargs["name"] = name
|
||||||
@@ -29,18 +32,16 @@ class RepositoryService(BaseService):
|
|||||||
repository_path = str(repository_path)
|
repository_path = str(repository_path)
|
||||||
if not repository_path.endswith(".git"):
|
if not repository_path.endswith(".git"):
|
||||||
repository_path += ".git"
|
repository_path += ".git"
|
||||||
command = ['git', 'init', '--bare', repository_path]
|
command = ["git", "init", "--bare", repository_path]
|
||||||
process = await asyncio.subprocess.create_subprocess_exec(
|
process = await asyncio.subprocess.create_subprocess_exec(
|
||||||
*command,
|
*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
return process.returncode == 0
|
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):
|
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):
|
if not await self.init(user_uid=user_uid, name=name):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from snek.model.user import UserModel
|
from snek.model.user import UserModel
|
||||||
from snek.system.service import BaseService
|
from snek.system.service import BaseService
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
|
|
||||||
|
|
||||||
class SocketService(BaseService):
|
class SocketService(BaseService):
|
||||||
|
|
||||||
class Socket:
|
class Socket:
|
||||||
@@ -15,7 +17,6 @@ class SocketService(BaseService):
|
|||||||
self.is_connected = True
|
self.is_connected = True
|
||||||
self.user = user
|
self.user = user
|
||||||
|
|
||||||
|
|
||||||
async def send_json(self, data):
|
async def send_json(self, data):
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
return False
|
return False
|
||||||
@@ -40,7 +41,6 @@ class SocketService(BaseService):
|
|||||||
self.users = {}
|
self.users = {}
|
||||||
self.subscriptions = {}
|
self.subscriptions = {}
|
||||||
self.last_update = str(datetime.now())
|
self.last_update = str(datetime.now())
|
||||||
|
|
||||||
|
|
||||||
async def user_availability_service(self):
|
async def user_availability_service(self):
|
||||||
logger.info("User availability update service started.")
|
logger.info("User availability update service started.")
|
||||||
@@ -50,14 +50,15 @@ class SocketService(BaseService):
|
|||||||
for s in self.sockets:
|
for s in self.sockets:
|
||||||
if not s.is_connected:
|
if not s.is_connected:
|
||||||
continue
|
continue
|
||||||
if not s.user in users_updated:
|
if s.user not in users_updated:
|
||||||
s.user["last_ping"] = now()
|
s.user["last_ping"] = now()
|
||||||
await self.app.services.user.save(s.user)
|
await self.app.services.user.save(s.user)
|
||||||
users_updated.append(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)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
async def add(self, ws, user_uid):
|
async def add(self, ws, user_uid):
|
||||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||||
self.sockets.add(s)
|
self.sockets.add(s)
|
||||||
@@ -81,7 +82,6 @@ class SocketService(BaseService):
|
|||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
async def broadcast(self, channel_uid, message):
|
async def broadcast(self, channel_uid, message):
|
||||||
await self._broadcast(channel_uid, message)
|
await self._broadcast(channel_uid, message)
|
||||||
|
|
||||||
@@ -102,4 +102,3 @@ class SocketService(BaseService):
|
|||||||
await s.close()
|
await s.close()
|
||||||
logger.info(f"Removed socket for user {s.user['username']}")
|
logger.info(f"Removed socket for user {s.user['username']}")
|
||||||
self.sockets.remove(s)
|
self.sockets.remove(s)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from snek.system.service import BaseService
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -32,14 +32,14 @@ class UserService(BaseService):
|
|||||||
user["color"] = await self.services.util.random_light_hex_color()
|
user["color"] = await self.services.util.random_light_hex_color()
|
||||||
return await super().save(user)
|
return await super().save(user)
|
||||||
|
|
||||||
def authenticate_sync(self,username,password):
|
def authenticate_sync(self, username, password):
|
||||||
user = self.get_by_username_sync(username)
|
user = self.get_by_username_sync(username)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
if not security.verify_sync(password, user["password"]):
|
if not security.verify_sync(password, user["password"]):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def authenticate(self, username, password):
|
async def authenticate(self, username, password):
|
||||||
success = await self.validate_login(username, password)
|
success = await self.validate_login(username, password)
|
||||||
@@ -61,14 +61,12 @@ class UserService(BaseService):
|
|||||||
return None
|
return None
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def get_template_path(self, user_uid):
|
async def get_template_path(self, user_uid):
|
||||||
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
|
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def get_by_username_sync(self, username):
|
def get_by_username_sync(self, username):
|
||||||
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
|
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
|
||||||
return dict(user)
|
return dict(user)
|
||||||
|
|||||||
@@ -1,48 +1,51 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import aiohttp
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
import shutil
|
logging.basicConfig(
|
||||||
import json
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
import tempfile
|
)
|
||||||
import asyncio
|
logger = logging.getLogger("git_server")
|
||||||
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):
|
class GitApplication(web.Application):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
#import git
|
# import git
|
||||||
#globals()['git'] = git
|
# globals()['git'] = git
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
super().__init__(client_max_size=1024*1024*1024*5)
|
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
|
||||||
self.add_routes([
|
self.add_routes(
|
||||||
web.post('/create/{repo_name}', self.create_repository),
|
[
|
||||||
web.delete('/delete/{repo_name}', self.delete_repository),
|
web.post("/create/{repo_name}", self.create_repository),
|
||||||
web.get('/clone/{repo_name}', self.clone_repository),
|
web.delete("/delete/{repo_name}", self.delete_repository),
|
||||||
web.post('/push/{repo_name}', self.push_repository),
|
web.get("/clone/{repo_name}", self.clone_repository),
|
||||||
web.post('/pull/{repo_name}', self.pull_repository),
|
web.post("/push/{repo_name}", self.push_repository),
|
||||||
web.get('/status/{repo_name}', self.status_repository),
|
web.post("/pull/{repo_name}", self.pull_repository),
|
||||||
#web.get('/list', self.list_repositories),
|
web.get("/status/{repo_name}", self.status_repository),
|
||||||
web.get('/branches/{repo_name}', self.list_branches),
|
# web.get('/list', self.list_repositories),
|
||||||
web.post('/branches/{repo_name}', self.create_branch),
|
web.get("/branches/{repo_name}", self.list_branches),
|
||||||
web.get('/log/{repo_name}', self.commit_log),
|
web.post("/branches/{repo_name}", self.create_branch),
|
||||||
web.get('/file/{repo_name}/{file_path:.*}', self.file_content),
|
web.get("/log/{repo_name}", self.commit_log),
|
||||||
web.get('/{path:.+}/info/refs', self.git_smart_http),
|
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
|
||||||
web.post('/{path:.+}/git-upload-pack', self.git_smart_http),
|
web.get("/{path:.+}/info/refs", self.git_smart_http),
|
||||||
web.post('/{path:.+}/git-receive-pack', self.git_smart_http),
|
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
|
||||||
web.get('/{repo_name}.git/info/refs', self.git_smart_http),
|
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
|
||||||
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),
|
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
|
||||||
web.post('/{repo_name}.git/git-receive-pack', 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):
|
async def check_basic_auth(self, request):
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
if not auth_header.startswith("Basic "):
|
if not auth_header.startswith("Basic "):
|
||||||
return None,None
|
return None, None
|
||||||
encoded_creds = auth_header.split("Basic ")[1]
|
encoded_creds = auth_header.split("Basic ")[1]
|
||||||
decoded_creds = base64.b64decode(encoded_creds).decode()
|
decoded_creds = base64.b64decode(encoded_creds).decode()
|
||||||
username, password = decoded_creds.split(":", 1)
|
username, password = decoded_creds.split(":", 1)
|
||||||
@@ -50,27 +53,31 @@ class GitApplication(web.Application):
|
|||||||
username=username, password=password
|
username=username, password=password
|
||||||
)
|
)
|
||||||
if not request["user"]:
|
if not request["user"]:
|
||||||
return None,None
|
return None, None
|
||||||
request["repository_path"] = await self.parent.services.user.get_repository_path(
|
request["repository_path"] = (
|
||||||
request["user"]["uid"]
|
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
|
@staticmethod
|
||||||
def require_auth(handler):
|
def require_auth(handler):
|
||||||
async def wrapped(self, request, *args, **kwargs):
|
async def wrapped(self, request, *args, **kwargs):
|
||||||
username, repository_path = await self.check_basic_auth(request)
|
username, repository_path = await self.check_basic_auth(request)
|
||||||
if not username or not repository_path:
|
if not username or not repository_path:
|
||||||
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')
|
return web.Response(
|
||||||
request['username'] = username
|
status=401,
|
||||||
request['repository_path'] = repository_path
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
text="Authentication required",
|
||||||
|
)
|
||||||
|
request["username"] = username
|
||||||
|
request["repository_path"] = repository_path
|
||||||
return await handler(self, request, *args, **kwargs)
|
return await handler(self, request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
def repo_path(self, repository_path, repo_name):
|
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):
|
def check_repo_exists(self, repository_path, repo_name):
|
||||||
repo_dir = self.repo_path(repository_path, repo_name)
|
repo_dir = self.repo_path(repository_path, repo_name)
|
||||||
@@ -80,10 +87,10 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def create_repository(self, request):
|
async def create_repository(self, request):
|
||||||
username = request['username']
|
username = request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
if not repo_name or '/' in repo_name or '..' in repo_name:
|
if not repo_name or "/" in repo_name or ".." in repo_name:
|
||||||
return web.Response(text="Invalid repository name", status=400)
|
return web.Response(text="Invalid repository name", status=400)
|
||||||
repo_dir = self.repo_path(repository_path, repo_name)
|
repo_dir = self.repo_path(repository_path, repo_name)
|
||||||
if os.path.exists(repo_dir):
|
if os.path.exists(repo_dir):
|
||||||
@@ -98,9 +105,9 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def delete_repository(self, request):
|
async def delete_repository(self, request):
|
||||||
username = request['username']
|
username = request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@@ -115,9 +122,9 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def clone_repository(self, request):
|
async def clone_repository(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@@ -126,15 +133,15 @@ class GitApplication(web.Application):
|
|||||||
response_data = {
|
response_data = {
|
||||||
"repository": repo_name,
|
"repository": repo_name,
|
||||||
"clone_command": f"git clone {clone_url}",
|
"clone_command": f"git clone {clone_url}",
|
||||||
"clone_url": clone_url
|
"clone_url": clone_url,
|
||||||
}
|
}
|
||||||
return web.json_response(response_data)
|
return web.json_response(response_data)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def push_repository(self, request):
|
async def push_repository(self, request):
|
||||||
username = request['username']
|
username = request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@@ -142,34 +149,40 @@ class GitApplication(web.Application):
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return web.Response(text="Invalid JSON data", status=400)
|
return web.Response(text="Invalid JSON data", status=400)
|
||||||
commit_message = data.get('commit_message', 'Update from server')
|
commit_message = data.get("commit_message", "Update from server")
|
||||||
branch = data.get('branch', 'main')
|
branch = data.get("branch", "main")
|
||||||
changes = data.get('changes', [])
|
changes = data.get("changes", [])
|
||||||
if not changes:
|
if not changes:
|
||||||
return web.Response(text="No changes provided", status=400)
|
return web.Response(text="No changes provided", status=400)
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
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:
|
for change in changes:
|
||||||
file_path = os.path.join(temp_dir, change.get('file', ''))
|
file_path = os.path.join(temp_dir, change.get("file", ""))
|
||||||
content = change.get('content', '')
|
content = change.get("content", "")
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
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)
|
f.write(content)
|
||||||
temp_repo.git.add(A=True)
|
temp_repo.git.add(A=True)
|
||||||
if not temp_repo.config_reader().has_section('user'):
|
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(
|
||||||
temp_repo.config_writer().set_value("user", "email", "git@server.local").release()
|
"user", "name", "Git Server"
|
||||||
|
).release()
|
||||||
|
temp_repo.config_writer().set_value(
|
||||||
|
"user", "email", "git@server.local"
|
||||||
|
).release()
|
||||||
temp_repo.index.commit(commit_message)
|
temp_repo.index.commit(commit_message)
|
||||||
origin = temp_repo.remote('origin')
|
origin = temp_repo.remote("origin")
|
||||||
origin.push(refspec=f"{branch}:{branch}")
|
origin.push(refspec=f"{branch}:{branch}")
|
||||||
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
||||||
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def pull_repository(self, request):
|
async def pull_repository(self, request):
|
||||||
username = request['username']
|
username = request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@@ -177,13 +190,15 @@ class GitApplication(web.Application):
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data = {}
|
data = {}
|
||||||
remote_url = data.get('remote_url')
|
remote_url = data.get("remote_url")
|
||||||
branch = data.get('branch', 'main')
|
branch = data.get("branch", "main")
|
||||||
if not remote_url:
|
if not remote_url:
|
||||||
return web.Response(text="Remote URL is required", status=400)
|
return web.Response(text="Remote URL is required", status=400)
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
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"
|
remote_name = "pull_source"
|
||||||
try:
|
try:
|
||||||
remote = local_repo.create_remote(remote_name, remote_url)
|
remote = local_repo.create_remote(remote_name, remote_url)
|
||||||
@@ -192,38 +207,46 @@ class GitApplication(web.Application):
|
|||||||
remote.set_url(remote_url)
|
remote.set_url(remote_url)
|
||||||
remote.fetch()
|
remote.fetch()
|
||||||
local_repo.git.merge(f"{remote_name}/{branch}")
|
local_repo.git.merge(f"{remote_name}/{branch}")
|
||||||
origin = local_repo.remote('origin')
|
origin = local_repo.remote("origin")
|
||||||
origin.push()
|
origin.push()
|
||||||
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}")
|
logger.info(
|
||||||
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error pulling to {repo_name}: {str(e)}")
|
logger.error(f"Error pulling to {repo_name}: {str(e)}")
|
||||||
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
|
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def status_repository(self, request):
|
async def status_repository(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
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]
|
branches = [b.name for b in temp_repo.branches]
|
||||||
active_branch = temp_repo.active_branch.name
|
active_branch = temp_repo.active_branch.name
|
||||||
commits = []
|
commits = []
|
||||||
for commit in list(temp_repo.iter_commits(max_count=5)):
|
for commit in list(temp_repo.iter_commits(max_count=5)):
|
||||||
commits.append({
|
commits.append(
|
||||||
"id": commit.hexsha,
|
{
|
||||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
"id": commit.hexsha,
|
||||||
"date": commit.committed_datetime.isoformat(),
|
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||||
"message": commit.message
|
"date": commit.committed_datetime.isoformat(),
|
||||||
})
|
"message": commit.message,
|
||||||
|
}
|
||||||
|
)
|
||||||
files = []
|
files = []
|
||||||
for root, dirs, filenames in os.walk(temp_dir):
|
for root, dirs, filenames in os.walk(temp_dir):
|
||||||
if '.git' in root:
|
if ".git" in root:
|
||||||
continue
|
continue
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
full_path = os.path.join(root, filename)
|
full_path = os.path.join(root, filename)
|
||||||
@@ -234,50 +257,58 @@ class GitApplication(web.Application):
|
|||||||
"branches": branches,
|
"branches": branches,
|
||||||
"active_branch": active_branch,
|
"active_branch": active_branch,
|
||||||
"recent_commits": commits,
|
"recent_commits": commits,
|
||||||
"files": files
|
"files": files,
|
||||||
}
|
}
|
||||||
return web.json_response(status_info)
|
return web.json_response(status_info)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting status for {repo_name}: {str(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
|
@require_auth
|
||||||
async def list_repositories(self, request):
|
async def list_repositories(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
try:
|
try:
|
||||||
repos = []
|
repos = []
|
||||||
user_dir = self.REPO_DIR
|
user_dir = self.REPO_DIR
|
||||||
if os.path.exists(user_dir):
|
if os.path.exists(user_dir):
|
||||||
for item in os.listdir(user_dir):
|
for item in os.listdir(user_dir):
|
||||||
item_path = os.path.join(user_dir, item)
|
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])
|
repos.append(item[:-4])
|
||||||
if request.query.get('format') == 'json':
|
if request.query.get("format") == "json":
|
||||||
return web.json_response({"repositories": repos})
|
return web.json_response({"repositories": repos})
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error listing repositories: {str(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
|
@require_auth
|
||||||
async def list_branches(self, request):
|
async def list_branches(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
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]
|
branches = [b.name for b in temp_repo.branches]
|
||||||
return web.json_response({"branches": branches})
|
return web.json_response({"branches": branches})
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def create_branch(self, request):
|
async def create_branch(self, request):
|
||||||
username = request['username']
|
username = request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@@ -285,145 +316,168 @@ class GitApplication(web.Application):
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return web.Response(text="Invalid JSON data", status=400)
|
return web.Response(text="Invalid JSON data", status=400)
|
||||||
branch_name = data.get('branch_name')
|
branch_name = data.get("branch_name")
|
||||||
start_point = data.get('start_point', 'HEAD')
|
start_point = data.get("start_point", "HEAD")
|
||||||
if not branch_name:
|
if not branch_name:
|
||||||
return web.Response(text="Branch name is required", status=400)
|
return web.Response(text="Branch name is required", status=400)
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
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.branch(branch_name, start_point)
|
||||||
temp_repo.git.push('origin', branch_name)
|
temp_repo.git.push("origin", branch_name)
|
||||||
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}")
|
logger.info(
|
||||||
|
f"Created branch {branch_name} in repository {repo_name} for user {username}"
|
||||||
|
)
|
||||||
return web.Response(text=f"Created branch {branch_name}")
|
return web.Response(text=f"Created branch {branch_name}")
|
||||||
except Exception as e:
|
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)
|
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def commit_log(self, request):
|
async def commit_log(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
try:
|
try:
|
||||||
limit = int(request.query.get('limit', 10))
|
limit = int(request.query.get("limit", 10))
|
||||||
branch = request.query.get('branch', 'main')
|
branch = request.query.get("branch", "main")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.Response(text="Invalid limit parameter", status=400)
|
return web.Response(text="Invalid limit parameter", status=400)
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
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 = []
|
commits = []
|
||||||
try:
|
try:
|
||||||
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
|
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
|
||||||
commits.append({
|
commits.append(
|
||||||
"id": commit.hexsha,
|
{
|
||||||
"short_id": commit.hexsha[:7],
|
"id": commit.hexsha,
|
||||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
"short_id": commit.hexsha[:7],
|
||||||
"date": commit.committed_datetime.isoformat(),
|
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||||
"message": commit.message.strip()
|
"date": commit.committed_datetime.isoformat(),
|
||||||
})
|
"message": commit.message.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
except git.GitCommandError as e:
|
except git.GitCommandError as e:
|
||||||
if "unknown revision or path" in str(e):
|
if "unknown revision or path" in str(e):
|
||||||
commits = []
|
commits = []
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
"repository": repo_name,
|
{"repository": repo_name, "branch": branch, "commits": commits}
|
||||||
"branch": branch,
|
)
|
||||||
"commits": commits
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting commit log for {repo_name}: {str(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
|
@require_auth
|
||||||
async def file_content(self, request):
|
async def file_content(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repo_name = request.match_info['repo_name']
|
repo_name = request.match_info["repo_name"]
|
||||||
file_path = request.match_info.get('file_path', '')
|
file_path = request.match_info.get("file_path", "")
|
||||||
branch = request.query.get('branch', 'main')
|
branch = request.query.get("branch", "main")
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
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:
|
try:
|
||||||
temp_repo.git.checkout(branch)
|
temp_repo.git.checkout(branch)
|
||||||
except git.GitCommandError:
|
except git.GitCommandError:
|
||||||
return web.Response(text=f"Branch '{branch}' not found", status=404)
|
return web.Response(text=f"Branch '{branch}' not found", status=404)
|
||||||
file_full_path = os.path.join(temp_dir, file_path)
|
file_full_path = os.path.join(temp_dir, file_path)
|
||||||
if not os.path.exists(file_full_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):
|
if os.path.isdir(file_full_path):
|
||||||
files = os.listdir(file_full_path)
|
files = os.listdir(file_full_path)
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
"repository": repo_name,
|
{
|
||||||
"path": file_path,
|
"repository": repo_name,
|
||||||
"type": "directory",
|
"path": file_path,
|
||||||
"contents": files
|
"type": "directory",
|
||||||
})
|
"contents": files,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with open(file_full_path, 'r') as f:
|
with open(file_full_path) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return web.Response(text=content)
|
return web.Response(text=content)
|
||||||
except UnicodeDecodeError:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error getting file content from {repo_name}: {str(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
|
@require_auth
|
||||||
async def git_smart_http(self, request):
|
async def git_smart_http(self, request):
|
||||||
username = request['username']
|
request["username"]
|
||||||
repository_path = request['repository_path']
|
repository_path = request["repository_path"]
|
||||||
path = request.path
|
path = request.path
|
||||||
|
|
||||||
async def get_repository_path():
|
async def get_repository_path():
|
||||||
req_path = path.lstrip('/')
|
req_path = path.lstrip("/")
|
||||||
if req_path.endswith('/info/refs'):
|
if req_path.endswith("/info/refs"):
|
||||||
repo_name = req_path[:-len('/info/refs')]
|
repo_name = req_path[: -len("/info/refs")]
|
||||||
elif req_path.endswith('/git-upload-pack'):
|
elif req_path.endswith("/git-upload-pack"):
|
||||||
repo_name = req_path[:-len('/git-upload-pack')]
|
repo_name = req_path[: -len("/git-upload-pack")]
|
||||||
elif req_path.endswith('/git-receive-pack'):
|
elif req_path.endswith("/git-receive-pack"):
|
||||||
repo_name = req_path[:-len('/git-receive-pack')]
|
repo_name = req_path[: -len("/git-receive-pack")]
|
||||||
else:
|
else:
|
||||||
repo_name = req_path
|
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_name = repo_name[4:]
|
repo_name = repo_name[4:]
|
||||||
repo_dir = repository_path.joinpath(repo_name + ".git")
|
repo_dir = repository_path.joinpath(repo_name + ".git")
|
||||||
logger.info(f"Resolved repo path: {repo_dir}")
|
logger.info(f"Resolved repo path: {repo_dir}")
|
||||||
return repo_dir
|
return repo_dir
|
||||||
|
|
||||||
async def handle_info_refs(service):
|
async def handle_info_refs(service):
|
||||||
repo_path = await get_repository_path()
|
repo_path = await get_repository_path()
|
||||||
|
|
||||||
logger.info(f"handle_info_refs: {repo_path}")
|
logger.info(f"handle_info_refs: {repo_path}")
|
||||||
if not os.path.exists(repo_path):
|
if not os.path.exists(repo_path):
|
||||||
return web.Response(text="Repository not found", status=404)
|
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:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
)
|
||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
logger.error(f"Git command failed: {stderr.decode()}")
|
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(
|
response = web.StreamResponse(
|
||||||
status=200,
|
status=200,
|
||||||
reason='OK',
|
reason="OK",
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': f'application/x-{service}-advertisement',
|
"Content-Type": f"application/x-{service}-advertisement",
|
||||||
'Cache-Control': 'no-cache'
|
"Cache-Control": "no-cache",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
await response.prepare(request)
|
await response.prepare(request)
|
||||||
packet = f"# service={service}\n"
|
packet = f"# service={service}\n"
|
||||||
@@ -435,48 +489,58 @@ class GitApplication(web.Application):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling info/refs: {str(e)}")
|
logger.error(f"Error handling info/refs: {str(e)}")
|
||||||
return web.Response(text=f"Server error: {str(e)}", status=500)
|
return web.Response(text=f"Server error: {str(e)}", status=500)
|
||||||
|
|
||||||
async def handle_service_rpc(service):
|
async def handle_service_rpc(service):
|
||||||
repo_path = await get_repository_path()
|
repo_path = await get_repository_path()
|
||||||
logger.info(f"handle_service_rpc: {repo_path}")
|
logger.info(f"handle_service_rpc: {repo_path}")
|
||||||
if not os.path.exists(repo_path):
|
if not os.path.exists(repo_path):
|
||||||
return web.Response(text="Repository not found", status=404)
|
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)
|
return web.Response(text="Invalid Content-Type", status=403)
|
||||||
body = await request.read()
|
body = await request.read()
|
||||||
cmd = [service, '--stateless-rpc', str(repo_path)]
|
cmd = [service, "--stateless-rpc", str(repo_path)]
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await process.communicate(input=body)
|
stdout, stderr = await process.communicate(input=body)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
logger.error(f"Git command failed: {stderr.decode()}")
|
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(
|
return web.Response(
|
||||||
body=stdout,
|
body=stdout, content_type=f"application/x-{service}-result"
|
||||||
content_type=f'application/x-{service}-result'
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling service RPC: {str(e)}")
|
logger.error(f"Error handling service RPC: {str(e)}")
|
||||||
return web.Response(text=f"Server error: {str(e)}", status=500)
|
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 request.method == "GET" and path.endswith("/info/refs"):
|
||||||
if service in ('git-upload-pack', 'git-receive-pack'):
|
service = request.query.get("service")
|
||||||
|
if service in ("git-upload-pack", "git-receive-pack"):
|
||||||
return await handle_info_refs(service)
|
return await handle_info_refs(service)
|
||||||
else:
|
else:
|
||||||
return web.Response(text="Smart HTTP requires service parameter", status=400)
|
return web.Response(
|
||||||
elif request.method == 'POST' and '/git-upload-pack' in path:
|
text="Smart HTTP requires service parameter", status=400
|
||||||
return await handle_service_rpc('git-upload-pack')
|
)
|
||||||
elif request.method == 'POST' and '/git-receive-pack' in path:
|
elif request.method == "POST" and "/git-upload-pack" in path:
|
||||||
return await handle_service_rpc('git-receive-pack')
|
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)
|
return web.Response(text="Not found", status=404)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
logger.info("Using uvloop for improved performance")
|
logger.info("Using uvloop for improved performance")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from snek.app import Application
|
||||||
|
from IPython import start_ipython
|
||||||
|
|
||||||
|
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
|
ENABLED = False
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiohttp import web
|
import json
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
import dataset
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
queue = asyncio.Queue()
|
queue = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
do_not_sync = False
|
do_not_sync = False
|
||||||
|
|
||||||
|
|
||||||
async def sync_service(app):
|
async def sync_service(app):
|
||||||
if not ENABLED:
|
if not ENABLED:
|
||||||
return
|
return
|
||||||
session = aiohttp.ClientSession()
|
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():
|
async def receive():
|
||||||
|
|
||||||
queries_synced = 0
|
queries_synced = 0
|
||||||
@@ -31,7 +31,7 @@ async def sync_service(app):
|
|||||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
try:
|
try:
|
||||||
data = json.loads(msg.data)
|
data = json.loads(msg.data)
|
||||||
State.do_not_sync = True
|
State.do_not_sync = True
|
||||||
app.db.execute(*data)
|
app.db.execute(*data)
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
State.do_not_sync = False
|
State.do_not_sync = False
|
||||||
@@ -41,21 +41,25 @@ async def sync_service(app):
|
|||||||
await app.services.socket.broadcast_event()
|
await app.services.socket.broadcast_event()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
pass
|
pass
|
||||||
#print(f"Received: {msg.data}")
|
# print(f"Received: {msg.data}")
|
||||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
break
|
break
|
||||||
|
|
||||||
async def write():
|
async def write():
|
||||||
while True:
|
while True:
|
||||||
msg = await queue.get()
|
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()
|
queue.task_done()
|
||||||
|
|
||||||
await asyncio.gather(receive(), write())
|
await asyncio.gather(receive(), write())
|
||||||
|
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
queries_queued = 0
|
queries_queued = 0
|
||||||
|
|
||||||
|
|
||||||
# Attach a listener to log all executed statements
|
# Attach a listener to log all executed statements
|
||||||
@event.listens_for(Engine, "before_cursor_execute")
|
@event.listens_for(Engine, "before_cursor_execute")
|
||||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||||
@@ -63,7 +67,7 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
|
|||||||
return
|
return
|
||||||
global queries_queued
|
global queries_queued
|
||||||
if State.do_not_sync:
|
if State.do_not_sync:
|
||||||
print(statement,parameters)
|
print(statement, parameters)
|
||||||
return
|
return
|
||||||
if statement.startswith("SELECT"):
|
if statement.startswith("SELECT"):
|
||||||
return
|
return
|
||||||
@@ -71,17 +75,18 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
|
|||||||
queries_queued += 1
|
queries_queued += 1
|
||||||
print("Queries queued: " + str(queries_queued))
|
print("Queries queued: " + str(queries_queued))
|
||||||
|
|
||||||
|
|
||||||
async def websocket_handler(request):
|
async def websocket_handler(request):
|
||||||
queries_broadcasted = 0
|
queries_broadcasted = 0
|
||||||
ws = web.WebSocketResponse()
|
ws = web.WebSocketResponse()
|
||||||
await ws.prepare(request)
|
await ws.prepare(request)
|
||||||
request.app['websockets'].append(ws)
|
request.app["websockets"].append(ws)
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
for client in request.app['websockets']:
|
for client in request.app["websockets"]:
|
||||||
if client != ws:
|
if client != ws:
|
||||||
await client.send_str(msg.data)
|
await client.send_str(msg.data)
|
||||||
cursor = request.app['db'].cursor()
|
cursor = request.app["db"].cursor()
|
||||||
data = json.loads(msg.data)
|
data = json.loads(msg.data)
|
||||||
queries_broadcasted += 1
|
queries_broadcasted += 1
|
||||||
|
|
||||||
@@ -89,28 +94,32 @@ async def websocket_handler(request):
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
print("Queries broadcasted: " + str(queries_broadcasted))
|
print("Queries broadcasted: " + str(queries_broadcasted))
|
||||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
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
|
return ws
|
||||||
|
|
||||||
app = web.Application()
|
|
||||||
app['websockets'] = []
|
|
||||||
|
|
||||||
app.router.add_get('/ws', websocket_handler)
|
app = web.Application()
|
||||||
|
app["websockets"] = []
|
||||||
|
|
||||||
|
app.router.add_get("/ws", websocket_handler)
|
||||||
|
|
||||||
|
|
||||||
async def on_startup(app):
|
async def on_startup(app):
|
||||||
app['db'] = sqlite3.connect('snek.db')
|
app["db"] = sqlite3.connect("snek.db")
|
||||||
print("Server starting...")
|
print("Server starting...")
|
||||||
|
|
||||||
|
|
||||||
async def on_cleanup(app):
|
async def on_cleanup(app):
|
||||||
for ws in app['websockets']:
|
for ws in app["websockets"]:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
app['db'].close()
|
app["db"].close()
|
||||||
|
|
||||||
|
|
||||||
app.on_startup.append(on_startup)
|
app.on_startup.append(on_startup)
|
||||||
app.on_cleanup.append(on_cleanup)
|
app.on_cleanup.append(on_cleanup)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
web.run_app(app, host='127.0.0.1', port=3131)
|
web.run_app(app, host="127.0.0.1", port=3131)
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import asyncssh
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
global _app
|
import asyncssh
|
||||||
|
|
||||||
|
global _app
|
||||||
|
|
||||||
|
|
||||||
def set_app(app):
|
def set_app(app):
|
||||||
global _app
|
global _app
|
||||||
_app = app
|
_app = app
|
||||||
|
|
||||||
|
|
||||||
def get_app():
|
def get_app():
|
||||||
return _app
|
return _app
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
roots = {}
|
roots = {}
|
||||||
|
|
||||||
|
|
||||||
class SFTPServer(asyncssh.SFTPServer):
|
class SFTPServer(asyncssh.SFTPServer):
|
||||||
|
|
||||||
def __init__(self, chan: asyncssh.SSHServerChannel):
|
def __init__(self, chan: asyncssh.SSHServerChannel):
|
||||||
self.root = get_app().services.user.get_home_folder_by_username(
|
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.mkdir(exist_ok=True)
|
||||||
self.root = str(self.root)
|
self.root = str(self.root)
|
||||||
@@ -30,20 +35,22 @@ class SFTPServer(asyncssh.SFTPServer):
|
|||||||
logger.debug(f"Mapping client path {path} to {mapped_path}")
|
logger.debug(f"Mapping client path {path} to {mapped_path}")
|
||||||
return str(mapped_path).encode()
|
return str(mapped_path).encode()
|
||||||
|
|
||||||
|
|
||||||
class SSHServer(asyncssh.SSHServer):
|
class SSHServer(asyncssh.SSHServer):
|
||||||
def password_auth_supported(self):
|
def password_auth_supported(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_password(self, username, password):
|
def validate_password(self, username, password):
|
||||||
logger.debug(f"Validating credentials for user {username}")
|
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}")
|
logger.info(f"Validating credentials for user {username}: {result}")
|
||||||
return 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")
|
logger.info("Starting SFTP server setup")
|
||||||
|
|
||||||
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
||||||
host_key_path.parent.mkdir(exist_ok=True, parents=True)
|
host_key_path.parent.mkdir(exist_ok=True, parents=True)
|
||||||
try:
|
try:
|
||||||
@@ -63,14 +70,12 @@ async def start_ssh_server(app,host,port):
|
|||||||
x = await asyncssh.listen(
|
x = await asyncssh.listen(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
#process_factory=handle_client,
|
# process_factory=handle_client,
|
||||||
server_host_keys=[key],
|
server_host_keys=[key],
|
||||||
server_factory=SSHServer,
|
server_factory=SSHServer,
|
||||||
sftp_factory=SFTPServer
|
sftp_factory=SFTPServer,
|
||||||
)
|
)
|
||||||
return x
|
return x
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"Failed to start SFTP server. Already running.")
|
logger.warning(f"Failed to start SFTP server. Already running.")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { Schedule } from "./schedule.js";
|
import { Schedule } from "./schedule.js";
|
||||||
import { EventHandler } from "./event-handler.js";
|
import { EventHandler } from "./event-handler.js";
|
||||||
import { Socket } from "./socket.js";
|
import { Socket } from "./socket.js";
|
||||||
|
import { Njet } from "./njet.js";
|
||||||
export class RESTClient {
|
export class RESTClient {
|
||||||
debug = false;
|
debug = false;
|
||||||
|
|
||||||
@@ -174,7 +174,15 @@ export class App extends EventHandler {
|
|||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
this.is_pinging = false;
|
this.is_pinging = false;
|
||||||
}
|
}
|
||||||
|
ntsh(times,message) {
|
||||||
|
if(!message)
|
||||||
|
message = "Nothing to see here!"
|
||||||
|
if(!times)
|
||||||
|
times=100
|
||||||
|
for(let x = 0; x < times; x++){
|
||||||
|
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
|
||||||
|
}
|
||||||
|
}
|
||||||
async forcePing(...arg) {
|
async forcePing(...arg) {
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ html {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
padding: 50px;
|
padding: 50px;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -221,6 +225,55 @@ footer {
|
|||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-content .spoiler {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
/*color: transparent;*/
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
height: 2.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .spoiler * {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active {
|
||||||
|
/*color: #e6e6e6;*/
|
||||||
|
/*transition: color 0.3s ease-in;*/
|
||||||
|
height: unset;
|
||||||
|
overflow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes delay-pointer-events {
|
||||||
|
0% {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spoiler:hover * {
|
||||||
|
animation: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in;
|
||||||
|
pointer-events: auto;
|
||||||
|
visibility: visible;
|
||||||
|
animation: delay-pointer-events 0.2s linear;
|
||||||
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -542,7 +595,31 @@ dialog .dialog-button.secondary {
|
|||||||
dialog .dialog-button.secondary:hover {
|
dialog .dialog-button.secondary:hover {
|
||||||
background-color: #f0b84c;
|
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 {
|
.embed-url-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -584,4 +661,8 @@ dialog .dialog-button.secondary:hover {
|
|||||||
color: #f05a28;
|
color: #f05a28;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { app } from "../app.js";
|
import { app } from "./app.js";
|
||||||
|
import { NjetComponent } from "./njet.js";
|
||||||
class ChatInputComponent extends HTMLElement {
|
import { FileUploadGrid } from "./file-upload-grid.js";
|
||||||
|
class ChatInputComponent extends NjetComponent {
|
||||||
autoCompletions = {
|
autoCompletions = {
|
||||||
"example 1": () => {},
|
"example 1": () => {
|
||||||
"example 2": () => {},
|
},
|
||||||
|
"example 2": () => {
|
||||||
|
},
|
||||||
}
|
}
|
||||||
hiddenCompletions = {
|
hiddenCompletions = {
|
||||||
"/starsRender": () => {
|
"/starsRender": () => {
|
||||||
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
users = []
|
users = []
|
||||||
textarea = null
|
textarea = null
|
||||||
_value = ""
|
_value = ""
|
||||||
lastUpdateEvent = null
|
lastUpdateEvent = null
|
||||||
previousValue = ""
|
expiryTimer = null;
|
||||||
lastChange = null
|
queuedMessage = null;
|
||||||
changed = false
|
lastMessagePromise = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.lastUpdateEvent = new Date();
|
this.lastUpdateEvent = new Date();
|
||||||
this.textarea = document.createElement("textarea");
|
this.textarea = document.createElement("textarea");
|
||||||
this.value = this.getAttribute("value") || "";
|
this.value = this.getAttribute("value") || "";
|
||||||
this.previousValue = this.value;
|
|
||||||
this.lastChange = new Date();
|
|
||||||
this.changed = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
@@ -32,24 +33,27 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set value(value) {
|
set value(value) {
|
||||||
this._value = value || "";
|
this._value = value;
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
get allAutoCompletions() {
|
get allAutoCompletions() {
|
||||||
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
|
return Object.assign({}, this.autoCompletions, this.hiddenCompletions)
|
||||||
}
|
}
|
||||||
resolveAutoComplete() {
|
|
||||||
let count = 0;
|
resolveAutoComplete(input) {
|
||||||
let value = null;
|
let value = null;
|
||||||
|
|
||||||
Object.keys(this.allAutoCompletions).forEach((key) => {
|
for (const key of Object.keys(this.allAutoCompletions)) {
|
||||||
if (key.startsWith(this.value.split(" ")[0])) {
|
if (key.startsWith(input.split(" ", 1)[0])) {
|
||||||
count++;
|
if (value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
value = key;
|
value = key;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
if (count == 1) return value;
|
|
||||||
return null;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive() {
|
isActive() {
|
||||||
@@ -58,120 +62,111 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
this.textarea.focus();
|
this.textarea.focus();
|
||||||
}
|
|
||||||
getAuthors(){
|
|
||||||
let authors = []
|
|
||||||
for (let i = 0; i < this.users.length; i++) {
|
|
||||||
authors.push(this.users[i].username)
|
|
||||||
authors.push(this.users[i].nick)
|
|
||||||
}
|
|
||||||
return authors
|
|
||||||
|
|
||||||
}
|
|
||||||
extractMentions(text) {
|
|
||||||
const regex = /@([a-zA-Z0-9_-]+)/g;
|
|
||||||
const mentions = [];
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
mentions.push(match[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mentions;
|
getAuthors() {
|
||||||
}
|
return this.users.flatMap((user) => [user.username, user.nick])
|
||||||
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)) {
|
extractMentions(text) {
|
||||||
distance += 10
|
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchMentionsToAuthors(mentions, authors) {
|
||||||
|
return mentions.map(mention => {
|
||||||
|
let closestAuthor = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
const lowerMention = mention.toLowerCase();
|
||||||
|
|
||||||
|
authors.forEach(author => {
|
||||||
|
const lowerAuthor = author.toLowerCase();
|
||||||
|
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||||
|
|
||||||
|
|
||||||
|
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
|
||||||
|
distance += 10
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distance < minDistance) {
|
if (distance < minDistance) {
|
||||||
minDistance = distance;
|
minDistance = distance;
|
||||||
closestAuthor = author;
|
closestAuthor = author;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
return { mention, closestAuthor, distance: minDistance };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
levenshteinDistance(a, b) {
|
||||||
|
const matrix = [];
|
||||||
|
|
||||||
|
// Initialize the first row and column
|
||||||
|
for (let i = 0; i <= b.length; i++) {
|
||||||
|
matrix[i] = [i];
|
||||||
|
}
|
||||||
|
for (let j = 0; j <= a.length; j++) {
|
||||||
|
matrix[0][j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in the matrix
|
||||||
|
for (let i = 1; i <= b.length; i++) {
|
||||||
|
for (let j = 1; j <= a.length; j++) {
|
||||||
|
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||||
|
matrix[i][j] = matrix[i - 1][j - 1];
|
||||||
|
} else {
|
||||||
|
matrix[i][j] = Math.min(
|
||||||
|
matrix[i - 1][j] + 1, // Deletion
|
||||||
|
matrix[i][j - 1] + 1, // Insertion
|
||||||
|
matrix[i - 1][j - 1] + 1 // Substitution
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[b.length][a.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
replaceMentionsWithAuthors(text) {
|
||||||
|
const authors = this.getAuthors();
|
||||||
|
const mentions = this.extractMentions(text);
|
||||||
|
|
||||||
|
const matches = this.matchMentionsToAuthors(mentions, authors);
|
||||||
|
let updatedText = text;
|
||||||
|
matches.forEach(({ mention, closestAuthor }) => {
|
||||||
|
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
||||||
|
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { mention, closestAuthor, distance: minDistance };
|
return updatedText;
|
||||||
});
|
|
||||||
}
|
|
||||||
levenshteinDistance(a, b) {
|
|
||||||
const matrix = [];
|
|
||||||
|
|
||||||
// Initialize the first row and column
|
|
||||||
for (let i = 0; i <= b.length; i++) {
|
|
||||||
matrix[i] = [i];
|
|
||||||
}
|
}
|
||||||
for (let j = 0; j <= a.length; j++) {
|
|
||||||
matrix[0][j] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill in the matrix
|
|
||||||
for (let i = 1; i <= b.length; i++) {
|
|
||||||
for (let j = 1; j <= a.length; j++) {
|
|
||||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
||||||
matrix[i][j] = matrix[i - 1][j - 1];
|
|
||||||
} else {
|
|
||||||
matrix[i][j] = Math.min(
|
|
||||||
matrix[i - 1][j] + 1, // Deletion
|
|
||||||
matrix[i][j - 1] + 1, // Insertion
|
|
||||||
matrix[i - 1][j - 1] + 1 // Substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[b.length][a.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
replaceMentionsWithAuthors(text) {
|
|
||||||
const authors = this.getAuthors();
|
|
||||||
const mentions = this.extractMentions(text);
|
|
||||||
|
|
||||||
const matches = this.matchMentionsToAuthors(mentions, authors);
|
|
||||||
let updatedText = text;
|
|
||||||
matches.forEach(({ mention, closestAuthor }) => {
|
|
||||||
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
|
||||||
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
this.user = null
|
this.user = null
|
||||||
app.rpc.getUser(null).then((user) => {
|
app.rpc.getUser(null).then((user) => {
|
||||||
this.user=user
|
this.user = user
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const me = this;
|
|
||||||
this.liveType = this.getAttribute("live-type") === "true";
|
this.liveType = this.getAttribute("live-type") === "true";
|
||||||
this.liveTypeInterval =
|
this.liveTypeInterval =
|
||||||
parseInt(this.getAttribute("live-type-interval")) || 6;
|
parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||||
this.channelUid = this.getAttribute("channel");
|
this.channelUid = this.getAttribute("channel");
|
||||||
|
|
||||||
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
app.rpc.getRecentUsers(this.channelUid).then(users => {
|
||||||
this.users = users
|
this.users = users
|
||||||
})
|
})
|
||||||
this.messageUid = null;
|
this.messageUid = null;
|
||||||
|
|
||||||
this.classList.add("chat-input");
|
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("placeholder", "Type a message...");
|
||||||
this.textarea.setAttribute("rows", "2");
|
this.textarea.setAttribute("rows", "2");
|
||||||
|
|
||||||
@@ -185,188 +180,176 @@ levenshteinDistance(a, b) {
|
|||||||
this.uploadButton.addEventListener("uploaded", (e) => {
|
this.uploadButton.addEventListener("uploaded", (e) => {
|
||||||
this.dispatchEvent(new CustomEvent("uploaded", e));
|
this.dispatchEvent(new CustomEvent("uploaded", e));
|
||||||
});
|
});
|
||||||
|
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.appendChild(this.uploadButton);
|
||||||
|
this.textarea.addEventListener("blur", () => {
|
||||||
|
this.updateFromInput("");
|
||||||
|
});
|
||||||
this.textarea.addEventListener("keyup", (e) => {
|
this.textarea.addEventListener("keyup", (e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
this.value = "";
|
|
||||||
|
const message = this.replaceMentionsWithAuthors(this.value);
|
||||||
e.target.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;
|
return;
|
||||||
}
|
}
|
||||||
this.value = e.target.value;
|
|
||||||
this.changed = true;
|
this.updateFromInput(e.target.value);
|
||||||
this.update();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.textarea.addEventListener("keydown", (e) => {
|
this.textarea.addEventListener("keydown", (e) => {
|
||||||
this.value = e.target.value;
|
this.value = e.target.value;
|
||||||
let autoCompletion = null;
|
|
||||||
|
let autoCompletion = null;
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
autoCompletion = this.resolveAutoComplete();
|
autoCompletion = this.resolveAutoComplete(this.value);
|
||||||
if (autoCompletion) {
|
if (autoCompletion) {
|
||||||
e.target.value = autoCompletion;
|
e.target.value = autoCompletion;
|
||||||
this.value = autoCompletion;
|
this.value = autoCompletion;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
const message = me.replaceMentionsWithAuthors(this.value);
|
if (e.repeat) {
|
||||||
e.target.value = "";
|
this.updateFromInput(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.addEventListener("upload", (e) => {
|
||||||
this.focus();
|
this.focus();
|
||||||
});
|
});
|
||||||
this.addEventListener("uploaded", function (e) {
|
this.addEventListener("uploaded", function (e) {
|
||||||
let message = "";
|
let message = e.detail.files.reduce((message, file) => {
|
||||||
e.detail.files.forEach((file) => {
|
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||||
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
}, '');
|
||||||
});
|
app.rpc.sendMessage(this.channelUid, message, true);
|
||||||
app.rpc.sendMessage(this.channelUid, message,true);
|
|
||||||
});
|
});
|
||||||
setTimeout(()=>{
|
setTimeout(() => {
|
||||||
this.focus();
|
this.focus();
|
||||||
},1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
trackSecondsBetweenEvents(event1Time, event2Time) {
|
trackSecondsBetweenEvents(event1Time, event2Time) {
|
||||||
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
||||||
return millisecondsDifference / 1000;
|
return millisecondsDifference / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubsequence(s, t) {
|
isSubsequence(s, t) {
|
||||||
let i = 0, j = 0;
|
let i = 0, j = 0;
|
||||||
while (i < s.length && j < t.length) {
|
while (i < s.length && j < t.length) {
|
||||||
if (s[i] === t[j]) {
|
if (s[i] === t[j]) {
|
||||||
i++;
|
i++;
|
||||||
}
|
|
||||||
j++;
|
|
||||||
}
|
}
|
||||||
return i === s.length;
|
j++;
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus() {
|
flagTyping() {
|
||||||
if (this.liveType) {
|
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
|
|
||||||
this.lastUpdateEvent = new Date();
|
this.lastUpdateEvent = new Date();
|
||||||
if (
|
app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
|
||||||
typeof app !== "undefined" &&
|
});
|
||||||
app.rpc &&
|
}
|
||||||
typeof app.rpc.set_typing === "function"
|
}
|
||||||
) {
|
|
||||||
app.rpc.set_typing(this.channelUid, this.user.color);
|
finalizeMessage(messageUid) {
|
||||||
|
if (!messageUid) {
|
||||||
|
if (this.value.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
|
||||||
|
} else if (messageUid.startsWith("?")) {
|
||||||
|
const lastQueuedMessage = this.queuedMessage;
|
||||||
|
|
||||||
|
this.lastMessagePromise?.then((uid) => {
|
||||||
|
const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve();
|
||||||
|
return updatePromise.finally(() => {
|
||||||
|
return app.rpc.finalizeMessage(uid);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
app.rpc.finalizeMessage(messageUid)
|
||||||
|
}
|
||||||
|
this.value = "";
|
||||||
|
this.messageUid = null;
|
||||||
|
this.queuedMessage = null;
|
||||||
|
this.lastMessagePromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromInput(value) {
|
||||||
|
if (this.expiryTimer) {
|
||||||
|
clearTimeout(this.expiryTimer);
|
||||||
|
this.expiryTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
this.flagTyping()
|
||||||
|
|
||||||
|
if (this.liveType && value[0] !== "/") {
|
||||||
|
this.expiryTimer = setTimeout(() => {
|
||||||
|
this.finalizeMessage(this.messageUid)
|
||||||
|
}, this.liveTypeInterval * 1000);
|
||||||
|
|
||||||
|
|
||||||
|
const messageText = this.replaceMentionsWithAuthors(value);
|
||||||
|
if (this.messageUid?.startsWith("?")) {
|
||||||
|
this.queuedMessage = messageText;
|
||||||
|
} else if (this.messageUid) {
|
||||||
|
app.rpc.updateMessageText(this.messageUid, messageText).then((d) => {
|
||||||
|
if (!d.success) {
|
||||||
|
this.messageUid = null
|
||||||
|
this.updateFromInput(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const placeHolderId = "?" + crypto.randomUUID();
|
||||||
|
this.messageUid = placeHolderId;
|
||||||
|
|
||||||
|
this.lastMessagePromise = this.sendMessage(this.channelUid, messageText, !this.liveType).then(async (uid) => {
|
||||||
|
if (this.liveType && this.messageUid === placeHolderId) {
|
||||||
|
if (this.queuedMessage && this.queuedMessage !== messageText) {
|
||||||
|
await app.rpc.updateMessageText(uid, this.queuedMessage)
|
||||||
|
}
|
||||||
|
this.messageUid = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uid
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
async sendMessage(channelUid, value, is_final) {
|
||||||
const expired =
|
|
||||||
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
|
|
||||||
this.liveTypeInterval;
|
|
||||||
const changed = this.value !== this.previousValue;
|
|
||||||
|
|
||||||
if (changed || expired) {
|
|
||||||
this.lastChange = new Date();
|
|
||||||
this.updateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.previousValue = this.value;
|
|
||||||
|
|
||||||
if (this.liveType && expired) {
|
|
||||||
this.value = "";
|
|
||||||
this.previousValue = "";
|
|
||||||
this.messageUid = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
if (this.liveType) {
|
|
||||||
this.updateMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(channelUid, value,is_final) {
|
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await app.rpc.sendMessage(channelUid, value,is_final);
|
return await app.rpc.sendMessage(channelUid, value, is_final);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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)
|
||||||
|
}*/
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._fileInput) {
|
||||||
|
this._fileInput.value = ''; // Allow same file selection twice
|
||||||
|
this._fileInput.click();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.render();
|
||||||
|
this._fileInput.addEventListener('change', e => this.handleFiles(e.target.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://${location.host}/ws`);
|
||||||
|
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.reset()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('file-upload-grid', FileUploadGrid);
|
||||||
|
|
||||||
|
export { FileUploadGrid };
|
||||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,30 +1,58 @@
|
|||||||
{
|
{
|
||||||
"id": "snek",
|
"id": "snek",
|
||||||
"name": "Snek",
|
"name": "Snek",
|
||||||
"description": "Danger noodle",
|
"short_name": "Snek",
|
||||||
"display": "standalone",
|
"description": "Snek Software Development Community",
|
||||||
"orientation": "portrait",
|
"display": "standalone",
|
||||||
"scope": "/",
|
"orientation": "portrait",
|
||||||
"theme_color": "#000000",
|
"scope": "/",
|
||||||
"background_color": "#000000",
|
"start_url": "/web.html",
|
||||||
"related_applications": [],
|
"theme_color": "#000000",
|
||||||
"prefer_related_applications": false,
|
"background_color": "#000000",
|
||||||
"screenshots": [],
|
"dir": "ltr",
|
||||||
"dir": "ltr",
|
"lang": "en-US",
|
||||||
"lang": "en-US",
|
"icons": [
|
||||||
"launch_path": "/web.html",
|
{
|
||||||
"short_name": "Snek",
|
"src": "/image/snek_logo_32x32.png",
|
||||||
"start_url": "/web.html",
|
"type": "image/png",
|
||||||
"icons": [
|
"sizes": "32x32"
|
||||||
{
|
},
|
||||||
"src": "/image/snek192.png",
|
{
|
||||||
"type": "image/png",
|
"src": "/image/snek_logo_64x64.png",
|
||||||
"sizes": "192x192"
|
"type": "image/png",
|
||||||
},
|
"sizes": "64x64"
|
||||||
{
|
},
|
||||||
"src": "/image/snek512.png",
|
{
|
||||||
"type": "image/png",
|
"src": "/image/snek_logo_128x128.png",
|
||||||
"sizes": "512x512"
|
"type": "image/png",
|
||||||
}
|
"sizes": "128x128"
|
||||||
]
|
},
|
||||||
}
|
{
|
||||||
|
"src": "/image/snek_logo_144x144.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "144x144"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/image/snek_logo_192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/image/snek_logo_256x256.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/image/snek_logo_512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/image/snek_logo_1024x1024.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"related_applications": [],
|
||||||
|
"prefer_related_applications": false
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,15 +23,23 @@ class MessageList extends HTMLElement {
|
|||||||
const messagesContainer = this
|
const messagesContainer = this
|
||||||
messagesContainer.addEventListener('click', (e) => {
|
messagesContainer.addEventListener('click', (e) => {
|
||||||
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
||||||
|
|
||||||
const img = e.target;
|
const img = e.target;
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
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;'
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
|
||||||
|
|
||||||
|
const urlObj = new URL(img.currentSrc || img.src)
|
||||||
|
urlObj.searchParams.delete("width");
|
||||||
|
urlObj.searchParams.delete("height");
|
||||||
|
|
||||||
const fullImg = document.createElement('img');
|
const fullImg = document.createElement('img');
|
||||||
const urlObj = new URL(img.src); urlObj.search = '';
|
|
||||||
fullImg.src = urlObj.toString();
|
fullImg.src = urlObj.toString();
|
||||||
fullImg.alt = img.alt;
|
fullImg.alt = img.alt;
|
||||||
fullImg.style.maxWidth = '90%';
|
fullImg.style.maxWidth = '90%';
|
||||||
fullImg.style.maxHeight = '90%';
|
fullImg.style.maxHeight = '90%';
|
||||||
|
|
||||||
overlay.appendChild(fullImg);
|
overlay.appendChild(fullImg);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
_subscriptions = {}
|
||||||
|
_elements = []
|
||||||
|
_rest = 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();
|
||||||
|
if (!Njet._root) {
|
||||||
|
Njet._root = this
|
||||||
|
Njet._rest = new RestClient({ baseURL: config.baseURL || null })
|
||||||
|
}
|
||||||
|
this.root._elements.push(this)
|
||||||
|
this.classList.add('njet');
|
||||||
|
this.config = config;
|
||||||
|
this.render.call(this);
|
||||||
|
this.initProps(config);
|
||||||
|
if (typeof this.construct === 'function')
|
||||||
|
this.construct.call(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
initProps(config) {
|
||||||
|
const props = Object.keys(config)
|
||||||
|
props.forEach(prop => {
|
||||||
|
if (config[prop] !== undefined) {
|
||||||
|
this[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() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Component extends Njet {}
|
||||||
|
|
||||||
|
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');
|
||||||
|
this.style.position = 'fixed';
|
||||||
|
this.style.top = '50%';
|
||||||
|
this.style.left = '50%';
|
||||||
|
this.style.transform = 'translate(-50%, -50%)';
|
||||||
|
this.style.padding = '20px';
|
||||||
|
this.style.border = '1px solid #444';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
this.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.2)';
|
||||||
|
this.style.minWidth = '300px';
|
||||||
|
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 = 'flenjet-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 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);
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
window.njet = njet
|
||||||
|
|
||||||
|
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet};
|
||||||
@@ -1,34 +1,57 @@
|
|||||||
this.onpush = (event) => {
|
export const registerServiceWorker = async (silent = false) => {
|
||||||
console.log(event.data);
|
try {
|
||||||
// From here we can write the data to IndexedDB, send it to any open
|
const serviceWorkerRegistration = await navigator.serviceWorker
|
||||||
// windows, display a notification, etc.
|
.register("/service-worker.js")
|
||||||
};
|
|
||||||
|
await serviceWorkerRegistration.update()
|
||||||
|
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
const keyResponse = await fetch('/push.json')
|
||||||
|
const keyData = await keyResponse.json()
|
||||||
|
|
||||||
|
const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0))
|
||||||
|
|
||||||
|
const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true, applicationServerKey: publicKey,
|
||||||
|
})
|
||||||
|
|
||||||
navigator.serviceWorker
|
|
||||||
.register("/service-worker.js")
|
|
||||||
.then((serviceWorkerRegistration) => {
|
|
||||||
serviceWorkerRegistration.pushManager.subscribe().then(
|
|
||||||
(pushSubscription) => {
|
|
||||||
const subscriptionObject = {
|
const subscriptionObject = {
|
||||||
endpoint: pushSubscription.endpoint,
|
...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings,
|
||||||
keys: {
|
|
||||||
p256dh: pushSubscription.getKey("p256dh"),
|
|
||||||
auth: pushSubscription.getKey("auth"),
|
|
||||||
},
|
|
||||||
encoding: PushManager.supportedContentEncodings,
|
|
||||||
/* other app-specific data, such as user identity */
|
|
||||||
};
|
};
|
||||||
console.log(
|
console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject);
|
||||||
pushSubscription.endpoint,
|
|
||||||
pushSubscription,
|
const response = await fetch('/push.json', {
|
||||||
subscriptionObject,
|
method: 'POST', headers: {
|
||||||
);
|
'Content-Type': 'application/json',
|
||||||
// The push subscription details needed by the application
|
}, body: JSON.stringify(subscriptionObject),
|
||||||
// server are now available, and can be sent to it using,
|
})
|
||||||
// for example, the fetch() API.
|
|
||||||
},
|
if (!response.ok) {
|
||||||
(error) => {
|
throw new Error('Bad status code from server.');
|
||||||
console.error(error);
|
}
|
||||||
},
|
|
||||||
);
|
const responseData = await response.json();
|
||||||
});
|
console.log('Registration response', responseData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error registering service worker:", error);
|
||||||
|
if (!silent) {
|
||||||
|
alert("Registering push notifications failed. Please check your browser settings and try again.\n\n" + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
window.registerNotificationsServiceWorker = () => {
|
||||||
|
return Notification.requestPermission().then((permission) => {
|
||||||
|
if (permission === "granted") {
|
||||||
|
console.log("Permission was granted");
|
||||||
|
return registerServiceWorker();
|
||||||
|
} else if (permission === "denied") {
|
||||||
|
console.log("Permission was denied");
|
||||||
|
} else {
|
||||||
|
console.log("Permission was dismissed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
registerServiceWorker(true).catch(console.error);
|
||||||
|
|||||||
@@ -1,65 +1,64 @@
|
|||||||
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) => {
|
self.addEventListener("install", (event) => {
|
||||||
console.log("Service worker installed");
|
console.log("Service worker installing...");
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open("snek-cache").then((cache) => {
|
||||||
|
return cache.addAll([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.registration?.navigationPreload.enable());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
if (!(self.Notification && self.Notification.permission === "granted")) {
|
if (!self.Notification || self.Notification.permission !== "granted") {
|
||||||
return;
|
console.log("Notification permission not granted");
|
||||||
}
|
return;
|
||||||
console.log("Received a push message", event);
|
}
|
||||||
|
|
||||||
const data = event.data?.json() ?? {};
|
const data = event.data?.json() ?? {};
|
||||||
const title = data.title || "Something Has Happened";
|
console.log("Received a push message", event, data);
|
||||||
const message =
|
|
||||||
data.message || "Here's something you might want to check out.";
|
|
||||||
const icon = "images/new-notification.png";
|
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, {
|
const title = data.title || "Something Has Happened";
|
||||||
body: message,
|
const message =
|
||||||
tag: "simple-push-demo-notification",
|
data.message || "Here's something you might want to check out.";
|
||||||
icon,
|
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) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
console.log("Notification click Received.", event);
|
console.log("Notification click Received.", event);
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
event.waitUntil(clients.openWindow(
|
event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`));
|
||||||
"https://snek.molodetz.nl",));
|
});
|
||||||
});*/
|
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import yaml
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
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")
|
||||||
|
for name in self.list_instances():
|
||||||
|
proc = self.running_instances.get(name)
|
||||||
|
if not proc:
|
||||||
|
continue
|
||||||
|
if proc['proc'].returncode == None:
|
||||||
|
print("Stopping",name)
|
||||||
|
await proc['proc'].stop()
|
||||||
|
print("Stopped",name,"gracefully")
|
||||||
|
|
||||||
|
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 == 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,6 +1,7 @@
|
|||||||
DEFAULT_LIMIT = 30
|
DEFAULT_LIMIT = 30
|
||||||
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import asyncio
|
|
||||||
from snek.system.model import BaseModel
|
from snek.system.model import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -12,19 +13,21 @@ class BaseMapper:
|
|||||||
|
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
self.semaphore = asyncio.Semaphore(1)
|
||||||
self.default_limit = self.__class__.default_limit
|
self.default_limit = self.__class__.default_limit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.app.db
|
return self.app.db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def loop(self):
|
def loop(self):
|
||||||
return asyncio.get_event_loop()
|
return asyncio.get_event_loop()
|
||||||
|
|
||||||
async def run_in_executor(self, func, *args, **kwargs):
|
async def run_in_executor(self, func, *args, **kwargs):
|
||||||
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
async with self.semaphore:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
# return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
async def new(self):
|
async def new(self):
|
||||||
return self.model_class(mapper=self, app=self.app)
|
return self.model_class(mapper=self, app=self.app)
|
||||||
@@ -36,8 +39,8 @@ class BaseMapper:
|
|||||||
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
||||||
if uid:
|
if uid:
|
||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
|
|
||||||
record = await self.run_in_executor(self.table.find_one,**kwargs)
|
record = await self.run_in_executor(self.table.find_one, **kwargs)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record = dict(record)
|
record = dict(record)
|
||||||
@@ -48,7 +51,7 @@ class BaseMapper:
|
|||||||
return await self.model_class.from_record(mapper=self, record=record)
|
return await self.model_class.from_record(mapper=self, record=record)
|
||||||
|
|
||||||
async def exists(self, **kwargs):
|
async def exists(self, **kwargs):
|
||||||
return await self.run_in_executor(self.table.exists,**kwargs)
|
return await self.run_in_executor(self.table.exists, **kwargs)
|
||||||
|
|
||||||
async def count(self, **kwargs) -> int:
|
async def count(self, **kwargs) -> int:
|
||||||
return await self.run_in_executor(self.table.count, **kwargs)
|
return await self.run_in_executor(self.table.count, **kwargs)
|
||||||
@@ -69,9 +72,13 @@ class BaseMapper:
|
|||||||
yield model
|
yield model
|
||||||
|
|
||||||
async def query(self, sql, *args):
|
async def query(self, sql, *args):
|
||||||
for record in await self.run_in_executor(self.db.query,sql, *args):
|
for record in await self.run_in_executor(self.db.query, sql, *args):
|
||||||
yield dict(record)
|
yield dict(record)
|
||||||
|
|
||||||
|
async def update(self, model):
|
||||||
|
model.updated_at.update()
|
||||||
|
return await self.run_in_executor(self.table.update, model.record, ["uid"])
|
||||||
|
|
||||||
async def delete(self, **kwargs) -> int:
|
async def delete(self, **kwargs) -> int:
|
||||||
if not kwargs or not isinstance(kwargs, dict):
|
if not kwargs or not isinstance(kwargs, dict):
|
||||||
raise Exception("Can't execute delete with no filter.")
|
raise Exception("Can't execute delete with no filter.")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
|
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
|
||||||
|
import re
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.cache import time_cache_async
|
from app.cache import time_cache_async
|
||||||
@@ -12,9 +12,24 @@ from pygments.formatters import html
|
|||||||
from pygments.lexers import get_lexer_by_name
|
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):
|
class MarkdownRenderer(HTMLRenderer):
|
||||||
|
|
||||||
_allow_harmful_protocols = True
|
_allow_harmful_protocols = False
|
||||||
|
|
||||||
def __init__(self, app, template):
|
def __init__(self, app, template):
|
||||||
super().__init__(False, True)
|
super().__init__(False, True)
|
||||||
@@ -26,8 +41,8 @@ class MarkdownRenderer(HTMLRenderer):
|
|||||||
formatter = html.HtmlFormatter()
|
formatter = html.HtmlFormatter()
|
||||||
self.env.globals["highlight_styles"] = formatter.get_style_defs()
|
self.env.globals["highlight_styles"] = formatter.get_style_defs()
|
||||||
|
|
||||||
def _escape(self, str):
|
# def _escape(self, str):
|
||||||
return str ##escape(str)
|
# return str ##escape(str)
|
||||||
|
|
||||||
def get_lexer(self, lang, default="bash"):
|
def get_lexer(self, lang, default="bash"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,8 +6,32 @@
|
|||||||
|
|
||||||
# MIT License: This code is distributed under the MIT License.
|
# MIT License: This code is distributed under the MIT License.
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
csp_policy = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' https://*.cloudflare.com https://molodetz.nl 'nonce-{nonce}'; "
|
||||||
|
"style-src 'self' https://*.cloudflare.com https://molodetz.nl; "
|
||||||
|
"img-src 'self' https://*.cloudflare.com https://molodetz.nl data:; "
|
||||||
|
"connect-src 'self' https://*.cloudflare.com https://molodetz.nl;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_nonce():
|
||||||
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def csp_middleware(request, handler):
|
||||||
|
|
||||||
|
response = await handler(request)
|
||||||
|
return response
|
||||||
|
nonce = generate_nonce()
|
||||||
|
response.headers["Content-Security-Policy"] = csp_policy.format(nonce=nonce)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def no_cors_middleware(request, handler):
|
async def no_cors_middleware(request, handler):
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:
|
|||||||
obj = hashlib.sha256(salted)
|
obj = hashlib.sha256(salted)
|
||||||
return obj.hexdigest()
|
return obj.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
|
async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
|
||||||
return hash_sync(data, salt)
|
return hash_sync(data, salt)
|
||||||
|
|
||||||
|
|
||||||
def verify_sync(string: str, hashed: str) -> bool:
|
def verify_sync(string: str, hashed: str) -> bool:
|
||||||
"""Verify if the given string matches the hashed value.
|
"""Verify if the given string matches the hashed value.
|
||||||
|
|
||||||
@@ -78,5 +80,6 @@ def verify_sync(string: str, hashed: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
return hash_sync(string) == hashed
|
return hash_sync(string) == hashed
|
||||||
|
|
||||||
|
|
||||||
async def verify(string: str, hashed: str) -> bool:
|
async def verify(string: str, hashed: str) -> bool:
|
||||||
return verify_sync(string, hashed)
|
return verify_sync(string, hashed)
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class BaseService:
|
|||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
return await self.count(**kwargs) > 0
|
return await self.count(**kwargs) > 0
|
||||||
|
|
||||||
|
async def update(self, model):
|
||||||
|
return await self.mapper.update(model)
|
||||||
|
|
||||||
async def count(self, **kwargs):
|
async def count(self, **kwargs):
|
||||||
return await self.mapper.count(**kwargs)
|
return await self.mapper.count(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import parse_qs, urlparse
|
||||||
from app.cache import time_cache
|
|
||||||
|
import bleach
|
||||||
import emoji
|
import emoji
|
||||||
import requests
|
import requests
|
||||||
|
from app.cache import time_cache
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from jinja2 import TemplateSyntaxError, nodes
|
from jinja2 import TemplateSyntaxError, nodes
|
||||||
from jinja2.ext import Extension
|
from jinja2.ext import Extension
|
||||||
@@ -78,6 +79,48 @@ emoji.EMOJI_DATA[
|
|||||||
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
|
||||||
|
"img",
|
||||||
|
"video",
|
||||||
|
"audio",
|
||||||
|
"source",
|
||||||
|
"iframe",
|
||||||
|
"picture",
|
||||||
|
"span",
|
||||||
|
]
|
||||||
|
ALLOWED_ATTRIBUTES = {
|
||||||
|
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
||||||
|
"img": ["src", "alt", "title", "width", "height"],
|
||||||
|
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
|
||||||
|
"iframe": [
|
||||||
|
"src",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"frameborder",
|
||||||
|
"allow",
|
||||||
|
"allowfullscreen",
|
||||||
|
"title",
|
||||||
|
"referrerpolicy",
|
||||||
|
"style",
|
||||||
|
],
|
||||||
|
"video": ["src", "controls", "width", "height"],
|
||||||
|
"audio": ["src", "controls"],
|
||||||
|
"source": ["src", "type"],
|
||||||
|
"span": ["class"],
|
||||||
|
"picture": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(value):
|
||||||
|
return bleach.clean(
|
||||||
|
value,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_link_target_blank(text):
|
def set_link_target_blank(text):
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
|
|
||||||
@@ -90,6 +133,43 @@ def set_link_target_blank(text):
|
|||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_ATTRIBUTES = {
|
||||||
|
"href",
|
||||||
|
"src",
|
||||||
|
"alt",
|
||||||
|
"title",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"style",
|
||||||
|
"id",
|
||||||
|
"class",
|
||||||
|
"rel",
|
||||||
|
"type",
|
||||||
|
"name",
|
||||||
|
"value",
|
||||||
|
"placeholder",
|
||||||
|
"aria-hidden",
|
||||||
|
"aria-label",
|
||||||
|
"srcset",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def whitelist_attributes(html):
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
for tag in soup.find_all():
|
||||||
|
if hasattr(tag, "attrs"):
|
||||||
|
if tag.name in ["script", "form", "input"]:
|
||||||
|
tag.replace_with("")
|
||||||
|
continue
|
||||||
|
attrs = dict(tag.attrs)
|
||||||
|
for attr in list(attrs):
|
||||||
|
# Check if attribute is in the safe list or is a data-* attribute
|
||||||
|
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
|
||||||
|
del tag.attrs[attr]
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
def embed_youtube(text):
|
def embed_youtube(text):
|
||||||
soup = BeautifulSoup(text, "html.parser")
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
for element in soup.find_all("a"):
|
for element in soup.find_all("a"):
|
||||||
@@ -184,12 +264,12 @@ def enrich_image_rendering(text):
|
|||||||
for element in soup.find_all("img"):
|
for element in soup.find_all("img"):
|
||||||
if element.attrs["src"].startswith("/"):
|
if element.attrs["src"].startswith("/"):
|
||||||
element.attrs["src"] += "?width=240&height=240"
|
element.attrs["src"] += "?width=240&height=240"
|
||||||
picture_template = f'''
|
picture_template = f"""
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
|
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
|
||||||
<source srcset="{element.attrs["src"]}&format=webp" type="image/webp" />
|
<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"]}" />
|
<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"))
|
element.replace_with(BeautifulSoup(picture_template, "html.parser"))
|
||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
@@ -233,7 +313,7 @@ def linkify_https(text):
|
|||||||
return set_link_target_blank(str(soup))
|
return set_link_target_blank(str(soup))
|
||||||
|
|
||||||
|
|
||||||
@time_cache(timeout=60*60)
|
@time_cache(timeout=60 * 60)
|
||||||
def get_url_content(url):
|
def get_url_content(url):
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=5)
|
response = requests.get(url, timeout=5)
|
||||||
@@ -316,12 +396,11 @@ def embed_url(text):
|
|||||||
page_video = get_element_options(None, "video", "video", "video")
|
page_video = get_element_options(None, "video", "video", "video")
|
||||||
page_audio = get_element_options(None, "audio", "audio", "audio")
|
page_audio = get_element_options(None, "audio", "audio", "audio")
|
||||||
|
|
||||||
preview_size = (
|
(
|
||||||
get_element_options(None, None, None, "card")
|
get_element_options(None, None, None, "card")
|
||||||
or "summary_large_image"
|
or "summary_large_image"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
attachment_base = BeautifulSoup(str(element), "html.parser")
|
attachment_base = BeautifulSoup(str(element), "html.parser")
|
||||||
attachments[original_link_name] = attachment_base
|
attachments[original_link_name] = attachment_base
|
||||||
|
|
||||||
@@ -360,20 +439,28 @@ def embed_url(text):
|
|||||||
)
|
)
|
||||||
|
|
||||||
description_element.append(
|
description_element.append(
|
||||||
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
|
BeautifulSoup(
|
||||||
|
f'<strong class="page-name">{page_name}</strong>',
|
||||||
|
"html.parser",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
description_element.append(
|
description_element.append(
|
||||||
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
|
BeautifulSoup(
|
||||||
|
f"<p class='page-description'>{page_description or "No description available."}</p>",
|
||||||
|
"html.parser",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
description_element.append(
|
description_element.append(
|
||||||
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
|
BeautifulSoup(
|
||||||
|
f"<p class='page-original-link'>{original_link_name}</p>",
|
||||||
|
"html.parser",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
render_element.append(description_element_base)
|
render_element.append(description_element_base)
|
||||||
|
|
||||||
|
|
||||||
for attachment in attachments.values():
|
for attachment in attachments.values():
|
||||||
soup.append(attachment)
|
soup.append(attachment)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
class WebSocketClient:
|
class WebSocketClient:
|
||||||
def __init__(self, hostname, port):
|
def __init__(self, hostname, port):
|
||||||
self.buffer = b''
|
self.buffer = b""
|
||||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.port = port
|
self.port = port
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|
||||||
def __getattr__(self, method, *args, **kwargs):
|
def __getattr__(self, method, *args, **kwargs):
|
||||||
if method in self.__dict__.keys():
|
if method in self.__dict__.keys():
|
||||||
return self.__dict__[method]
|
return self.__dict__[method]
|
||||||
|
|
||||||
def call(*args, **kwargs):
|
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 json.loads(self.read())
|
||||||
|
|
||||||
return call
|
return call
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
self.socket.connect((self.hostname, self.port))
|
self.socket.connect((self.hostname, self.port))
|
||||||
key = base64.b64encode(b'1234123412341234').decode('utf-8')
|
key = base64.b64encode(b"1234123412341234").decode("utf-8")
|
||||||
handshake = (
|
handshake = (
|
||||||
f"GET /db HTTP/1.1\r\n"
|
f"GET /db HTTP/1.1\r\n"
|
||||||
f"Host: localhost:3131\r\n"
|
f"Host: localhost:3131\r\n"
|
||||||
@@ -25,57 +27,58 @@ class WebSocketClient:
|
|||||||
f"Sec-WebSocket-Key: {key}\r\n"
|
f"Sec-WebSocket-Key: {key}\r\n"
|
||||||
f"Sec-WebSocket-Version: 13\r\n\r\n"
|
f"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
)
|
)
|
||||||
self.socket.sendall(handshake.encode('utf-8'))
|
self.socket.sendall(handshake.encode("utf-8"))
|
||||||
response = self.read_until(b'\r\n\r\n')
|
response = self.read_until(b"\r\n\r\n")
|
||||||
if b'101 Switching Protocols' not in response:
|
if b"101 Switching Protocols" not in response:
|
||||||
raise Exception("Failed to connect to WebSocket")
|
raise Exception("Failed to connect to WebSocket")
|
||||||
|
|
||||||
def write(self, message):
|
def write(self, message):
|
||||||
message_bytes = message.encode('utf-8')
|
message_bytes = message.encode("utf-8")
|
||||||
length = len(message_bytes)
|
length = len(message_bytes)
|
||||||
if length <= 125:
|
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:
|
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:
|
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:
|
while True:
|
||||||
find_pos = self.buffer.find(delimiter)
|
find_pos = self.buffer.find(delimiter)
|
||||||
if find_pos != -1:
|
if find_pos != -1:
|
||||||
data = self.buffer[:find_pos+4]
|
data = self.buffer[: find_pos + 4]
|
||||||
self.buffer = self.buffer[find_pos+4:]
|
self.buffer = self.buffer[find_pos + 4 :]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
chunk = self.socket.recv(1024)
|
chunk = self.socket.recv(1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return None
|
return None
|
||||||
self.buffer += chunk
|
self.buffer += chunk
|
||||||
|
|
||||||
def read_exactly(self, length):
|
def read_exactly(self, length):
|
||||||
while len(self.buffer) < length:
|
while len(self.buffer) < length:
|
||||||
chunk = self.socket.recv(length - len(self.buffer))
|
chunk = self.socket.recv(length - len(self.buffer))
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return None
|
return None
|
||||||
self.buffer += chunk
|
self.buffer += chunk
|
||||||
response = self.buffer[: length]
|
response = self.buffer[:length]
|
||||||
self.buffer = self.buffer[length:]
|
self.buffer = self.buffer[length:]
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
frame = None
|
frame = None
|
||||||
frame = self.read_exactly(2)
|
frame = self.read_exactly(2)
|
||||||
length = frame[1] & 127
|
length = frame[1] & 127
|
||||||
if length == 126:
|
if length == 126:
|
||||||
length = int.from_bytes(self.read_exactly(2), 'big')
|
length = int.from_bytes(self.read_exactly(2), "big")
|
||||||
elif length == 127:
|
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)
|
message = self.read_exactly(length)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.socket.close()
|
self.socket.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,220 @@
|
|||||||
{% extends "base.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>About โ Snek Web Community</title>
|
||||||
|
<meta name="description" content="Snek is a privacy-centric web community platform engineered for reliability, security, and professional-grade design." />
|
||||||
|
<link rel="stylesheet" href="/sandbox.css" />
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI',sans-serif;
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
line-height:1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: #7ef; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.container { width: 90%; max-width: 800px; margin: auto; padding: 2rem 0; }
|
||||||
|
.about-hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0 2rem 0;
|
||||||
|
}
|
||||||
|
.about-hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
text-fill-color: transparent;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.about-hero img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
color: #7ef;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section p, .section ul {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section ul {
|
||||||
|
list-style: disc inside;
|
||||||
|
}
|
||||||
|
.topnav {
|
||||||
|
width: 100%;
|
||||||
|
background: #181818;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.topnav .container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.topnav a {
|
||||||
|
color: #7ef;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
transition: background 0.14s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.topnav a:hover, .topnav a.active {
|
||||||
|
background: #222b;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.topnav .home-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.topnav .home-link img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 0.1em 0 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.about-hero h1 { font-size: 2rem; }
|
||||||
|
.about-hero img { width: 48px; height: 48px; }
|
||||||
|
.section { padding: 1rem; }
|
||||||
|
.topnav .container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.topnav a {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
.topnav .home-link img {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
background: #181818;
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.4rem 0 1.3rem 0;
|
||||||
|
font-size: 1.01rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin-top: auto;
|
||||||
|
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
footer {
|
||||||
|
font-size: 0.97rem;
|
||||||
|
padding: 1rem 0 0.9rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="container">
|
||||||
|
<a class="home-link" href="/">
|
||||||
|
<img src="/image/snek_logo_256x256.png" alt="Snek Home Logo" />
|
||||||
|
Snek Community
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<header class="container about-hero">
|
||||||
|
<img src="/image/snek_logo_256x256.png" alt="Snek Logo" />
|
||||||
|
<h1>About Snek</h1>
|
||||||
|
<p>Snek is engineered for professionals who demand reliability, uncompromising security, and mature, clean design. This is a platform for those who expect seamless functionality and consistent results.</p>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="section">
|
||||||
|
<h2>What is Snek?</h2>
|
||||||
|
<p>
|
||||||
|
Snek is a privacy-driven web community and collaboration platform built with rigorous attention to reliability and operational clarity. Security, stability, and ease of use are foundationalโthere is no tolerance for unnecessary complexity or distractions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Onboarding is immediate: connect via WebDAV (<strong>davs://molodetz.online/webdav</strong>) or SFTP (<strong>sftp://molodetz.online:2242</strong>) using your Snek-issued credentials. Storage policies are governed by fair-use principles and subject to moderation to ensure operational integrity.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Repository management is centralized via user settings. Message editing is purposefully disabled by default to preserve communication fidelity and accountability.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The platform leverages <strong>aiohttp</strong> for high-performance asynchronous operations, coupled with a bespoke ORM ensuring data integrity and consistency. All validation is centralized for maximum efficiency. Deployment is streamlinedโsingle-command provisioning and full Docker support are standard. <strong>SQLite</strong> is the default database for simplicity, with <strong>PostgreSQL</strong> available for advanced requirements.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The frontend employs vanilla JavaScript exclusively, guaranteeing a lightweight, high-speed user experience with minimal maintenance overhead. Key features include real-time typing indicators, integrated web bash terminal, Markdown support, and server-side rendering for optimal performance. Snek exists to address the growing disregard for privacy and trust in mainstream platformsโhere, these are non-negotiable standards.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<h2>Why does Snek exist?</h2>
|
||||||
|
<p>
|
||||||
|
Leading chat platforms routinely prioritize data exploitation, restrictive ecosystems, or inflated costs. Snek takes a disciplined, user-focused approach: privacy is uncompromised, the feature set is purposeful, and superfluous elements are eliminated.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Snek was established in direct response to industry trendsโ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>
|
||||||
|
|
||||||
{% 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,10 +6,13 @@
|
|||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Snek</title>
|
<title>Snek</title>
|
||||||
<style>{{highlight_styles}}</style>
|
<style>{{highlight_styles}}</style>
|
||||||
|
<link rel="stylesheet" href="/file-upload-grid.css">
|
||||||
|
|
||||||
|
<script src="/njet.js" type="module"></script>
|
||||||
|
<script src="/file-upload-grid.js" type="module"></script>
|
||||||
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
||||||
<!--
|
|
||||||
<script src="/push.js"></script>
|
<script src="/push.js" type="module"></script>
|
||||||
-->
|
|
||||||
<script src="/fancy-button.js" type="module"></script>
|
<script src="/fancy-button.js" type="module"></script>
|
||||||
<script src="/upload-button.js" type="module"></script>
|
<script src="/upload-button.js" type="module"></script>
|
||||||
<script src="/generic-form.js" type="module"></script>
|
<script src="/generic-form.js" type="module"></script>
|
||||||
@@ -19,24 +22,16 @@
|
|||||||
<script src="/user-list.js"></script>
|
<script src="/user-list.js"></script>
|
||||||
<script src="/message-list.js" type="module"></script>
|
<script src="/message-list.js" type="module"></script>
|
||||||
<script src="/chat-input.js" type="module"></script>
|
<script src="/chat-input.js" type="module"></script>
|
||||||
|
<script src="/container.js" type="module"></script>
|
||||||
<link rel="stylesheet" href="/sandbox.css">
|
<link rel="stylesheet" href="/sandbox.css">
|
||||||
<link rel="stylesheet" href="/user-list.css">
|
<link rel="stylesheet" href="/user-list.css">
|
||||||
|
<link rel="stylesheet" href="/fa640.min.css">
|
||||||
<link rel="stylesheet" href="/base.css">
|
<link rel="stylesheet" href="/base.css">
|
||||||
<link
|
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
|
||||||
rel="stylesheet"
|
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
|
||||||
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>
|
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
||||||
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
|
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
|
||||||
@@ -46,6 +41,7 @@
|
|||||||
<a class="no-select" style="display:none" id="install-button" href="#">๐ฅ</a>
|
<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="/threads.html">๐ฅ</a>
|
||||||
<a class="no-select" href="/settings/index.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>
|
<a class="no-select" href="/logout.html">๐</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -54,13 +50,102 @@
|
|||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
{% include "sidebar_channels.html" %}
|
{% include "sidebar_channels.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<chat-window class="chat-area"></chat-window>
|
<chat-window class="chat-area"></chat-window>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<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() => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let installPrompt = null
|
let installPrompt = null
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
//e.preventDefault();
|
//e.preventDefault();
|
||||||
@@ -72,7 +157,6 @@ let installPrompt = null
|
|||||||
|
|
||||||
button.addEventListener("click", async ()=>{
|
button.addEventListener("click", async ()=>{
|
||||||
const result = await installPrompt.prompt()
|
const result = await installPrompt.prompt()
|
||||||
console.info(result.outcome)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<meta name="keywords" content="snek, chat, molodetz">
|
<meta name="keywords" content="snek, chat, molodetz">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/sandbox.css" />
|
||||||
<title>{% block title %}Snek chat by Molodetz{% endblock %}</title>
|
<title>{% block title %}Snek chat by Molodetz{% endblock %}</title>
|
||||||
|
|
||||||
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
<main>
|
<main>
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% include "sandbox.html" %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { NjetDialog } from "./njet.js"
|
||||||
|
|
||||||
|
function deleteChannel(channelUid){
|
||||||
|
const dialog = new NjetDialog({
|
||||||
|
title: "Delete channel?",
|
||||||
|
|
||||||
|
buttons: [{
|
||||||
|
text: "No"
|
||||||
|
},{
|
||||||
|
text: "Yes"
|
||||||
|
}]
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script>
|
||||||
|
class ContainerDialogComponent extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._channelUid = null;
|
||||||
|
this._dialog = null;
|
||||||
|
this._closeButton = null;
|
||||||
|
this._startButton = null;
|
||||||
|
this._restartButton = null;
|
||||||
|
this._stopButton = null;
|
||||||
|
this._statusSpan = null;
|
||||||
|
this._actions = null;
|
||||||
|
this._containerList = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._channelUid = this.getAttribute('channel');
|
||||||
|
// Render template
|
||||||
|
this.innerHTML = `
|
||||||
|
<dialog class="container-dialog">
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<div class="dialog-box">
|
||||||
|
<div class="dialog-title"><h2>Container</h2></div>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<table class="container-properties">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<td><span class="container-name"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Image</th>
|
||||||
|
<td><span class="container-image"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>CPUs</th>
|
||||||
|
<td><span class="container-cpus"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Memory</th>
|
||||||
|
<td><span class="container-memory"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<td><span class="container-status"></span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
|
||||||
|
<button class="dialog-button secondary btn-container-restart">Restart</button>
|
||||||
|
<button class="dialog-button secondary btn-container-stop">Stop</button>
|
||||||
|
<button class="dialog-button secondary btn-container-start">Start</button>
|
||||||
|
<button class="dialog-button primary btn-container-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this._dialog = this.querySelector('dialog');
|
||||||
|
this._closeButton = this.querySelector('.btn-container-close');
|
||||||
|
this._startButton = this.querySelector('.btn-container-start');
|
||||||
|
this._restartButton = this.querySelector('.btn-container-restart');
|
||||||
|
this._stopButton = this.querySelector('.btn-container-stop');
|
||||||
|
this._statusSpan = this.querySelector('.container-status');
|
||||||
|
this._imageSpan = this.querySelector('.container-image');
|
||||||
|
this._memorySpan = this.querySelector('.container-memory');
|
||||||
|
this._cpusSpan = this.querySelector('.container-cpus');
|
||||||
|
this._nameSpan = this.querySelector('.container-name');
|
||||||
|
this._actions = this.querySelector('.dialog-actions');
|
||||||
|
|
||||||
|
this._initEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set channelUid(val) {
|
||||||
|
this._channelUid = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
get channelUid() {
|
||||||
|
return this._channelUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initEventListeners() {
|
||||||
|
if (this._closeButton) {
|
||||||
|
this._closeButton.addEventListener('click', () => this.close());
|
||||||
|
}
|
||||||
|
if (this._startButton) {
|
||||||
|
this._startButton.addEventListener('click', () => this.startContainer());
|
||||||
|
}
|
||||||
|
if (this._restartButton) {
|
||||||
|
this._restartButton.addEventListener('click', () => this.restartContainer());
|
||||||
|
}
|
||||||
|
if (this._stopButton) {
|
||||||
|
this._stopButton.addEventListener('click', () => this.stopContainer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async openWithStatus() {
|
||||||
|
if (!this._channelUid) return;
|
||||||
|
const container = await (window.app?.rpc?.getContainer?.(this._channelUid) ?? Promise.resolve('unknown'));
|
||||||
|
this.updateContainerInfo(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContainerInfo(container) {
|
||||||
|
this.updateStatus(container['status']);
|
||||||
|
this.updateCpus(container['cpus']);
|
||||||
|
this.updateMemory(container['memory']);
|
||||||
|
this.updateImage(container['image']);
|
||||||
|
this.updateName(container['name']);
|
||||||
|
this._dialog.showModal();
|
||||||
|
this._closeButton.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(status) {
|
||||||
|
this._statusSpan.innerText = status;
|
||||||
|
if (status === 'running') {
|
||||||
|
this._stopButton.disabled = false;
|
||||||
|
this._startButton.disabled = true;
|
||||||
|
this._restartButton.disabled = false;
|
||||||
|
} else {
|
||||||
|
this._stopButton.disabled = true;
|
||||||
|
this._startButton.disabled = false;
|
||||||
|
this._restartButton.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCpus(cpus) {
|
||||||
|
if (this._cpusSpan) {
|
||||||
|
this._cpusSpan.innerText = cpus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemory(memory) {
|
||||||
|
if (this._memorySpan) {
|
||||||
|
this._memorySpan.innerText = memory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImage(image) {
|
||||||
|
if (this._imageSpan) {
|
||||||
|
this._imageSpan.innerText = image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateName(name) {
|
||||||
|
if (this._nameSpan) {
|
||||||
|
this._nameSpan.innerText = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this._dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for container operations
|
||||||
|
async startContainer() {
|
||||||
|
if (!this._channelUid) return;
|
||||||
|
if (window.app?.rpc?.startContainer) {
|
||||||
|
await window.app.rpc.startContainer(this._channelUid);
|
||||||
|
await this.openWithStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer() {
|
||||||
|
if (!this._channelUid) return;
|
||||||
|
if (window.app?.rpc?.stopContainer) {
|
||||||
|
await window.app.rpc.stopContainer(this._channelUid);
|
||||||
|
await this.openWithStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartContainer() {
|
||||||
|
if (!this._channelUid) return;
|
||||||
|
if (window.app?.rpc?.restartContainer) {
|
||||||
|
await window.app.rpc.restartContainer(this._channelUid);
|
||||||
|
await this.openWithStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('container-dialog', ContainerDialogComponent);
|
||||||
|
</script>
|
||||||
|
<container-dialog id="container-dialog" channel="{{ channel.uid.value }}"></container-dialog>
|
||||||
|
<script>
|
||||||
|
const containerDialog = document.getElementById('container-dialog');
|
||||||
|
</script>
|
||||||
@@ -1,133 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: white; /* Ensure the iframe has a white background */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Base reset */
|
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI',sans-serif;
|
|
||||||
background: #000;
|
|
||||||
color: #eee;
|
|
||||||
line-height:1.5;
|
|
||||||
}
|
|
||||||
a { color: #7ef; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 0;
|
|
||||||
}
|
|
||||||
.hero h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
.hero p {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin: 1rem 0 2rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: .75rem 1.5rem;
|
|
||||||
margin: .5rem;
|
|
||||||
background: #0fa;
|
|
||||||
color: #111;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background .2s;
|
|
||||||
}
|
|
||||||
.btn:hover { background: #7ef; }
|
|
||||||
|
|
||||||
/* Features grid */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #1a1a1a;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
|
||||||
}
|
|
||||||
.card h3 {
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
color: #7ef;
|
|
||||||
}
|
|
||||||
.card ul {
|
|
||||||
list-style: disc inside;
|
|
||||||
margin-top: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: .9rem;
|
|
||||||
padding: 2rem 0;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
footer code {
|
|
||||||
background: #222;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #7ef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile tweaks */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.hero h1 { font-size: 2.4rem; }
|
|
||||||
.btn { width: 100%; box-sizing: border-box; text-align:center; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Snek โ The Ultimate Web Community</title>
|
<title>Snek โ Professional Community for Developers, Testers & AI Professionals</title>
|
||||||
|
<meta name="description" content="Snek is a privacy-focused web platform for developers, testers, and AI professionals. Share files, collaborate on code, communicate securely, self-host, and utilize advanced AI toolsโall with rapid onboarding, no email, and no logs." />
|
||||||
|
<meta name="keywords" content="Snek, developer platform, web community, testers, AI, artificial intelligence, file sharing, Git, SFTP, WebDAV, communication, collaboration, open source, self-hosting, privacy, PWA, Ubuntu terminal, web terminal, audit-free, no email" />
|
||||||
|
<meta property="og:title" content="Snek โ Professional Community for Developers, Testers & AI Professionals" />
|
||||||
|
<meta property="og:description" content="Join Snek: a trusted, privacy-focused platform for developers, testers, and AI professionals. Collaborate, share, and self-host with confidence." />
|
||||||
|
<meta property="og:image" content="/image/snek_logo_256x256.png" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://snek.community/" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Snek โ Professional Community for Developers, Testers & AI Professionals" />
|
||||||
|
<meta name="twitter:description" content="A privacy-focused platform for developers, testers, and AI professionals. No email, no logsโjust secure collaboration and innovation." />
|
||||||
|
<meta name="twitter:image" content="/image/snek_logo_256x256.png" />
|
||||||
|
<link rel="canonical" href="https://snek.community/" />
|
||||||
|
<link rel="stylesheet" href="/sandbox.css" />
|
||||||
<style>
|
<style>
|
||||||
/* Base reset */
|
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
.card {
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.star {
|
||||||
|
z-index: -10;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI',sans-serif;
|
font-family: 'Segoe UI',sans-serif;
|
||||||
background: #111;
|
background: #111;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
line-height:1.5;
|
line-height:1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
a { color: #7ef; text-decoration: none; }
|
a { color: #7ef; text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
|
background-image: url('/image/snek_logo_256x256.png');
|
||||||
|
background-size: contain;
|
||||||
|
background-position: left center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
text-fill-color: transparent;
|
||||||
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
.hero p {
|
.hero p {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -142,10 +70,9 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background .2s;
|
transition: background .2s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn:hover { background: #7ef; }
|
.btn:hover { background: #7ef; }
|
||||||
|
|
||||||
/* Features grid */
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
@@ -161,13 +88,28 @@
|
|||||||
.card h3 {
|
.card h3 {
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
color: #7ef;
|
color: #7ef;
|
||||||
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
.card ul {
|
.card ul {
|
||||||
list-style: disc inside;
|
list-style: disc inside;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
section#signup,
|
||||||
/* Footer */
|
section#selfhost {
|
||||||
|
text-align:center;
|
||||||
|
margin:4rem 0;
|
||||||
|
}
|
||||||
|
section#selfhost pre {
|
||||||
|
display:inline-block;
|
||||||
|
background:#222;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:4px;
|
||||||
|
color:#7ef;
|
||||||
|
font-size:1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
@@ -180,140 +122,116 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #7ef;
|
color: #7ef;
|
||||||
}
|
}
|
||||||
|
.about-link {
|
||||||
/* Mobile tweaks */
|
display: inline-block;
|
||||||
|
margin: 1.5rem 0 0 0;
|
||||||
|
color: #0fa;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color .2s;
|
||||||
|
}
|
||||||
|
.about-link:hover {
|
||||||
|
color: #7ef;
|
||||||
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.hero h1 { font-size: 2.4rem; }
|
.hero h1 { font-size: 2.4rem; }
|
||||||
.btn { width: 100%; box-sizing: border-box; text-align:center; }
|
.btn { width: 100%; box-sizing: border-box; text-align:center; }
|
||||||
|
.hero {
|
||||||
|
background-size: 64px 64px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/sandbox.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header class="container hero">
|
||||||
<header class="container hero">
|
<h1>Snek</h1>
|
||||||
<h1>Snek</h1>
|
<p>Professional Platform for Developers, Testers & AI Professionals</p>
|
||||||
<p>The Ultimate Web Community for Devs, Testers & AI Enthusiasts</p>
|
<a href="/login.html" class="btn">Login</a>
|
||||||
<a href="/login.html" class="btn">Login</a>
|
<a href="/register.html" class="btn">Register</a>
|
||||||
<a href="/register.html" class="btn">Register</a>
|
<a href="/about.html" class="about-link">About</a>
|
||||||
</header>
|
</header>
|
||||||
|
<main class="container">
|
||||||
<main class="container">
|
<section id="features" class="grid">
|
||||||
|
<div class="card">
|
||||||
<section id="features" class="grid">
|
<h3>File Management</h3>
|
||||||
<div class="card">
|
<ul>
|
||||||
<h3>File Sharing</h3>
|
<li>Access secure SFTP storage with your Snek credentials</li>
|
||||||
<ul>
|
<li>WebDAV integration with unified authentication</li>
|
||||||
<li>SFTP storage with your Snek credentials</li>
|
</ul>
|
||||||
<li>WebDAV support โ same login</li>
|
</div>
|
||||||
</ul>
|
<div class="card">
|
||||||
</div>
|
<h3>Git Repository Hosting</h3>
|
||||||
|
<ul>
|
||||||
<div class="card">
|
<li>Configure repositories with any official Git client</li>
|
||||||
<h3>Git Repositories</h3>
|
<li>Immediate setup for push and pull operations</li>
|
||||||
<ul>
|
</ul>
|
||||||
<li>Configure repos for any official Git client</li>
|
</div>
|
||||||
<li>Instant setup, push & pull</li>
|
<div class="card">
|
||||||
</ul>
|
<h3>AI Integration</h3>
|
||||||
</div>
|
<ul>
|
||||||
|
<li>Interact with both free and commercial AI models</li>
|
||||||
<div class="card">
|
<li>AI-driven image generation</li>
|
||||||
<h3>AI Powerhouse</h3>
|
<li>Rapid custom AI bot deployment (example included)</li>
|
||||||
<ul>
|
</ul>
|
||||||
<li>Chat with free & commercial AIs</li>
|
</div>
|
||||||
<li>Generate AI-powered images</li>
|
<div class="card">
|
||||||
<li>Build your own AI bots in <5 minutes (copy/paste example)</li>
|
<h3>Development & Terminal Access</h3>
|
||||||
</ul>
|
<ul>
|
||||||
</div>
|
<li>In-browser Ubuntu terminal environment</li>
|
||||||
|
<li>Comprehensive profile and permission management</li>
|
||||||
<div class="card">
|
</ul>
|
||||||
<h3>Dev & Terminal</h3>
|
</div>
|
||||||
<ul>
|
<div class="card">
|
||||||
<li>Ubuntu web terminal in-browser</li>
|
<h3>Communication & Media</h3>
|
||||||
<li>Full profile & permissions management</li>
|
<ul>
|
||||||
</ul>
|
<li>Share any file format via chat</li>
|
||||||
</div>
|
<li>Rich media playback including audio, video, and images</li>
|
||||||
|
<li>Direct messaging functionality</li>
|
||||||
<div class="card">
|
</ul>
|
||||||
<h3>Chat & Media</h3>
|
</div>
|
||||||
<ul>
|
<div class="card">
|
||||||
<li>Upload any file type in chat</li>
|
<h3>Privacy & Global Community</h3>
|
||||||
<li>Rich media support (audio, video, imagesโฆ)</li>
|
<ul>
|
||||||
<li>Direct messaging with other users</li>
|
<li>Zero loggingโno IP or activity tracking</li>
|
||||||
</ul>
|
<li>Email-free registration process</li>
|
||||||
</div>
|
<li>International, open community</li>
|
||||||
|
<li>Ethical hacking and innovation encouraged</li>
|
||||||
<div class="card">
|
</ul>
|
||||||
<h3>Privacy & Community</h3>
|
</div>
|
||||||
<ul>
|
<div class="card">
|
||||||
<li>No loggingโnever even your IP</li>
|
<h3>Customization & Deployment</h3>
|
||||||
<li>No email required to sign up</li>
|
<ul>
|
||||||
<li>Multi-national, open community</li>
|
<li>Full layout and theme personalization</li>
|
||||||
<li>Hacking encouraged!</li>
|
<li>Install as a PWA for mobile and desktop</li>
|
||||||
</ul>
|
<li>Self-host with <code>pip install</code>; no configuration required</li>
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
<div class="card">
|
</section>
|
||||||
<h3>Customization & Deployment</h3>
|
<section id="signup">
|
||||||
<ul>
|
<h2>Start Now</h2>
|
||||||
<li>Full layout & theme customization</li>
|
<p>No email. No activity logs. Register a username and access the platform immediately.</p>
|
||||||
<li>Install as a PWA on your phone</li>
|
<a href="/register" class="btn">Sign Up</a>
|
||||||
<li>Optionally self-host: <code>pip install snek</code>, zero config</li>
|
<br>
|
||||||
</ul>
|
<a href="/about.html" class="about-link">Learn more about Snek</a>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
<section id="selfhost" style="margin-bottom:4rem;">
|
||||||
|
<h2>Deploy Your Own Instance</h2>
|
||||||
<section id="signup" style="text-align:center; margin:4rem 0;">
|
<p>Execute the following commands:</p>
|
||||||
<h2>Ready to join?</h2>
|
<pre>
|
||||||
<p>No email. No logs. Just sign up, pick a username, and dive in!</p>
|
|
||||||
<a href="/register" class="btn">Sign Up Now</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="selfhost" style="text-align:center; margin-bottom:4rem;">
|
|
||||||
<h2>Self-Host in Seconds</h2>
|
|
||||||
<p>Just run:</p>
|
|
||||||
<pre style="display:inline-block; background:#222; padding:1rem; border-radius:4px; color:#7ef;">
|
|
||||||
pip install git+https://retoor.molodetz.nl/retoor/snek.git
|
pip install git+https://retoor.molodetz.nl/retoor/snek.git
|
||||||
snek serve
|
snek serve
|
||||||
</pre>
|
</pre>
|
||||||
<p>No configuration requiredโit's that simple.</p>
|
<p>Deployment is immediateโno configuration is necessary.</p>
|
||||||
</section>
|
<a href="/about.html" class="about-link">Discover the benefits of self-hosting</a>
|
||||||
|
</section>
|
||||||
</main>
|
{% include "sandbox.html" %}
|
||||||
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<p>© 2025 Snek โ Join our global community of developers, testers & AI enthusiasts.</p>
|
<p>© 2025 Snek โ The premier global platform for developers, testers, and AI professionals.</p>
|
||||||
</footer>
|
<a href="/about.html" class="about-link">About Snek</a>
|
||||||
|
</footer>
|
||||||
<script>
|
|
||||||
|
|
||||||
// number of stars you want
|
|
||||||
const STAR_COUNT = 200;
|
|
||||||
const body = document.body;
|
|
||||||
|
|
||||||
for (let i = 0; i < STAR_COUNT; i++) {
|
|
||||||
const star = document.createElement('div');
|
|
||||||
star.classList.add('star');
|
|
||||||
|
|
||||||
// random position within the viewport
|
|
||||||
star.style.left = Math.random() * 100 + '%';
|
|
||||||
star.style.top = Math.random() * 100 + '%';
|
|
||||||
|
|
||||||
// random size (optional)
|
|
||||||
const size = Math.random() * 2 + 1; // between 1px and 3px
|
|
||||||
star.style.width = size + 'px';
|
|
||||||
star.style.height = size + 'px';
|
|
||||||
|
|
||||||
// random animation timing for natural flicker
|
|
||||||
const duration = Math.random() * 3 + 2; // 2sโ5s
|
|
||||||
const delay = Math.random() * 5; // 0sโ5s
|
|
||||||
star.style.animationDuration = duration + 's';
|
|
||||||
star.style.animationDelay = delay + 's';
|
|
||||||
|
|
||||||
body.appendChild(star);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,250 @@ class StarField {
|
|||||||
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
|
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
|
||||||
this._createStars();
|
this._createStars();
|
||||||
window.stars = this.positionMap;
|
window.stars = this.positionMap;
|
||||||
|
this.patchConsole()
|
||||||
|
this.starSignal = (() => {
|
||||||
|
const positionMap = this.positionMap;
|
||||||
|
|
||||||
|
const areaMap = {
|
||||||
|
Center: 'Center',
|
||||||
|
Corner: 'Corner',
|
||||||
|
Edge: 'Edge',
|
||||||
|
North: 'North',
|
||||||
|
South: 'South',
|
||||||
|
East: 'East',
|
||||||
|
West: 'West',
|
||||||
|
All: Object.keys(positionMap),
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEffect = (stars, effectFn, duration = 2000) => {
|
||||||
|
const active = new Set();
|
||||||
|
stars.forEach((star, i) => {
|
||||||
|
const { cleanup } = effectFn(star, i);
|
||||||
|
active.add(cleanup);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
active.forEach(fn => fn && fn());
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const effects = {
|
||||||
|
pulseColor: (color, size = 5) => (star) => {
|
||||||
|
const orig = {
|
||||||
|
color: star.style.backgroundColor,
|
||||||
|
width: star.style.width,
|
||||||
|
height: star.style.height,
|
||||||
|
shadow: star.style.boxShadow
|
||||||
|
};
|
||||||
|
star.style.backgroundColor = color;
|
||||||
|
star.style.width = `${size}px`;
|
||||||
|
star.style.height = `${size}px`;
|
||||||
|
star.style.boxShadow = `0 0 ${size * 2}px ${color}`;
|
||||||
|
return {
|
||||||
|
cleanup: () => {
|
||||||
|
star.style.backgroundColor = orig.color;
|
||||||
|
star.style.width = orig.width;
|
||||||
|
star.style.height = orig.height;
|
||||||
|
star.style.boxShadow = orig.shadow;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
flicker: (color) => (star) => {
|
||||||
|
let visible = true;
|
||||||
|
const orig = {
|
||||||
|
color: star.style.backgroundColor,
|
||||||
|
shadow: star.style.boxShadow
|
||||||
|
};
|
||||||
|
const flick = setInterval(() => {
|
||||||
|
star.style.backgroundColor = visible ? color : '';
|
||||||
|
star.style.boxShadow = visible ? `0 0 4px ${color}` : '';
|
||||||
|
visible = !visible;
|
||||||
|
}, 200);
|
||||||
|
return {
|
||||||
|
cleanup: () => {
|
||||||
|
clearInterval(flick);
|
||||||
|
star.style.backgroundColor = orig.color;
|
||||||
|
star.style.boxShadow = orig.shadow;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
shimmer: (colors) => (star, i) => {
|
||||||
|
const orig = star.style.backgroundColor;
|
||||||
|
let idx = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
star.style.backgroundColor = colors[idx % colors.length];
|
||||||
|
idx++;
|
||||||
|
}, 100 + (i % 5) * 10);
|
||||||
|
return {
|
||||||
|
cleanup: () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
star.style.backgroundColor = orig;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (signalName) => {
|
||||||
|
switch (signalName) {
|
||||||
|
// --- Notifications ---
|
||||||
|
case 'notif.newDM':
|
||||||
|
applyEffect(positionMap[areaMap.Corner], effects.pulseColor('white'));
|
||||||
|
break;
|
||||||
|
case 'notif.groupMsg':
|
||||||
|
applyEffect(positionMap[areaMap.Edge], effects.flicker('blue'), 1000);
|
||||||
|
break;
|
||||||
|
case 'notif.mention':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('magenta'));
|
||||||
|
break;
|
||||||
|
case 'notif.react':
|
||||||
|
applyEffect(positionMap[areaMap.South], effects.pulseColor('gold'), 1200);
|
||||||
|
break;
|
||||||
|
case 'notif.typing':
|
||||||
|
applyEffect(positionMap[areaMap.West], effects.pulseColor('teal'), 1500);
|
||||||
|
break;
|
||||||
|
case 'notif.msgSent':
|
||||||
|
applyEffect(positionMap[areaMap.East], effects.pulseColor('lightgreen'), 800);
|
||||||
|
break;
|
||||||
|
case 'notif.msgRead':
|
||||||
|
applyEffect(positionMap[areaMap.North], effects.pulseColor('lightblue'), 1000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// --- User Status ---
|
||||||
|
case 'status.online':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('green'), 3000);
|
||||||
|
break;
|
||||||
|
case 'status.offline':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('gray'), 3000);
|
||||||
|
break;
|
||||||
|
case 'status.idle':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('orange'), 3000);
|
||||||
|
break;
|
||||||
|
case 'status.typing':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.flicker('lightblue'), 2000);
|
||||||
|
break;
|
||||||
|
case 'status.join':
|
||||||
|
applyEffect(positionMap[areaMap.Edge], effects.pulseColor('cyan'), 1500);
|
||||||
|
break;
|
||||||
|
case 'status.leave':
|
||||||
|
applyEffect(positionMap[areaMap.Edge], effects.pulseColor('black'), 1500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// --- System ---
|
||||||
|
case 'sys.broadcast':
|
||||||
|
areaMap.All.forEach(area => {
|
||||||
|
applyEffect(positionMap[area], effects.pulseColor('gold'), 1000);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'sys.warning':
|
||||||
|
areaMap.All.forEach(area => {
|
||||||
|
applyEffect(positionMap[area], effects.flicker('red'), 2000);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'sys.down':
|
||||||
|
areaMap.All.forEach(area => {
|
||||||
|
applyEffect(positionMap[area], effects.flicker('darkred'), 3000);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'sys.update':
|
||||||
|
applyEffect(positionMap[areaMap.North], effects.shimmer(['cyan', 'violet', 'lime']), 2500);
|
||||||
|
break;
|
||||||
|
case 'sys.ping':
|
||||||
|
areaMap.All.forEach(area => {
|
||||||
|
applyEffect(positionMap[area], effects.pulseColor('white'), 500);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// --- Activity & Reactions ---
|
||||||
|
case 'activity.surge':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.shimmer(['white', 'blue', 'purple']), 1500);
|
||||||
|
break;
|
||||||
|
case 'reaction.burst':
|
||||||
|
applyEffect(positionMap[areaMap.South], effects.pulseColor('hotpink'), 1000);
|
||||||
|
break;
|
||||||
|
case 'reaction.lol':
|
||||||
|
applyEffect(positionMap[areaMap.South], effects.flicker('yellow'), 800);
|
||||||
|
break;
|
||||||
|
case 'reaction.likes':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('deeppink'), 1000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// --- Focus & Movement ---
|
||||||
|
case 'focus.zone':
|
||||||
|
applyEffect(positionMap[areaMap.Center], effects.pulseColor('white'), 1500);
|
||||||
|
break;
|
||||||
|
case 'focus.enter':
|
||||||
|
applyEffect(positionMap[areaMap.West], effects.pulseColor('cyan'), 1000);
|
||||||
|
break;
|
||||||
|
case 'focus.exit':
|
||||||
|
applyEffect(positionMap[areaMap.East], effects.pulseColor('gray'), 1000);
|
||||||
|
break;
|
||||||
|
case 'focus.idle':
|
||||||
|
applyEffect(positionMap[areaMap.North], effects.flicker('gray'), 2000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown star signal:', signalName);
|
||||||
|
}
|
||||||
|
this.trigger = this.starSignal.trigger;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { trigger };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
showLogEvent(...args) {
|
||||||
|
//this.showNotify(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
mapLogLevel(level) {
|
||||||
|
switch (level) {
|
||||||
|
case 'info':
|
||||||
|
return { category: 'Info', color: 'skyblue' };
|
||||||
|
case 'warn':
|
||||||
|
return { category: 'Warning', color: 'orange' };
|
||||||
|
case 'error':
|
||||||
|
return { category: 'Error', color: 'crimson' };
|
||||||
|
case 'log':
|
||||||
|
default:
|
||||||
|
return { category: 'Log', color: 'gold' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patchConsole(){
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const originalConsole = {
|
||||||
|
info: console.info,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
log: console.log,
|
||||||
|
};
|
||||||
|
const me = this
|
||||||
|
// Override console methods
|
||||||
|
console.info = function(...args) {
|
||||||
|
me.showLogEvent('info', args);
|
||||||
|
me.createConsoleStar('info', args);
|
||||||
|
originalConsole.info.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = function(...args) {
|
||||||
|
me.showLogEvent('warn', args);
|
||||||
|
me.createConsoleStar('warn', args);
|
||||||
|
originalConsole.warn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = function(...args) {
|
||||||
|
me.showLogEvent('error', args);
|
||||||
|
me.createConsoleStar('error', args);
|
||||||
|
originalConsole.error.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log = function(...args) {
|
||||||
|
me.showLogEvent('log', args);
|
||||||
|
me.createConsoleStar('log', args);
|
||||||
|
originalConsole.log.apply(console, args);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_getStarPosition(star) {
|
_getStarPosition(star) {
|
||||||
@@ -48,6 +292,21 @@ class StarField {
|
|||||||
star.shuffle = () => this._randomizeStar(star);
|
star.shuffle = () => this._randomizeStar(star);
|
||||||
star.position = this._getStarPosition(star);
|
star.position = this._getStarPosition(star);
|
||||||
}
|
}
|
||||||
|
createConsoleStar(level, args) {
|
||||||
|
const { category, color } = this.mapLogLevel(level);
|
||||||
|
const message = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||||
|
|
||||||
|
this.addSpecialStar({
|
||||||
|
title: category.toUpperCase(),
|
||||||
|
content: message,
|
||||||
|
category,
|
||||||
|
color,
|
||||||
|
onClick: () => {
|
||||||
|
this.showNotify(level, [`[${category}]`, message]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_placeStar(star) {
|
_placeStar(star) {
|
||||||
const pos = star.position;
|
const pos = star.position;
|
||||||
|
|||||||
@@ -3,9 +3,28 @@
|
|||||||
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
|
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
{% include "channel.html" %}
|
||||||
<section class="chat-area">
|
<section class="chat-area">
|
||||||
<message-list class="chat-messages">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
|
||||||
|
|
||||||
|
<div id="terminal" class="hidden"></div>
|
||||||
|
<message-list class="chat-messages">
|
||||||
|
{% if not messages %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to your new channel!</h1>
|
||||||
|
<p>This is the start of something great. Use the commands below to get started:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Press <code>/invite</code> to invite someone.</li>
|
||||||
|
<li>Press <code>/online</code> to see who's currently online.</li>
|
||||||
|
<li>Press <code>/help</code> to view all available commands.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Enjoy chatting!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
{% autoescape false %}
|
{% autoescape false %}
|
||||||
{{ message.html }}
|
{{ message.html }}
|
||||||
@@ -15,6 +34,7 @@
|
|||||||
</message-list>
|
</message-list>
|
||||||
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
||||||
</section>
|
</section>
|
||||||
|
{% include "dialog_container.html" %}
|
||||||
{% include "dialog_help.html" %}
|
{% include "dialog_help.html" %}
|
||||||
{% include "dialog_online.html" %}
|
{% include "dialog_online.html" %}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
@@ -27,13 +47,15 @@ const messagesContainer = document.querySelector(".chat-messages");
|
|||||||
const chatArea = document.querySelector(".chat-area");
|
const chatArea = document.querySelector(".chat-area");
|
||||||
const channelUid = "{{ channel.uid.value }}";
|
const channelUid = "{{ channel.uid.value }}";
|
||||||
const username = "{{ user.username.value }}";
|
const username = "{{ user.username.value }}";
|
||||||
|
|
||||||
// --- Command completions ---
|
// --- Command completions ---
|
||||||
chatInputField.autoCompletions = {
|
chatInputField.autoCompletions = {
|
||||||
"/online": showOnline,
|
"/online": showOnline,
|
||||||
"/clear": () => { messagesContainer.innerHTML = ''; },
|
"/clear": () => { messagesContainer.innerHTML = ''; },
|
||||||
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
||||||
"/help": showHelp
|
"/help": showHelp,
|
||||||
|
"/container": async() =>{
|
||||||
|
containerDialog.openWithStatus()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Throttle utility ---
|
// --- Throttle utility ---
|
||||||
|
|||||||
@@ -31,21 +31,17 @@ from multiavatar import multiavatar
|
|||||||
from snek.system.view import BaseView
|
from snek.system.view import BaseView
|
||||||
from snek.view.avatar_animal import generate_avatar_with_options
|
from snek.view.avatar_animal import generate_avatar_with_options
|
||||||
|
|
||||||
import functools
|
|
||||||
|
|
||||||
class AvatarView(BaseView):
|
class AvatarView(BaseView):
|
||||||
login_required = False
|
login_required = False
|
||||||
|
|
||||||
def __init__(self, *args,**kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args,**kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.avatars = {}
|
self.avatars = {}
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
type_ = self.request.query.get("type")
|
type_ = self.request.query.get("type")
|
||||||
type_match = {
|
type_match = {"animal": self.get_animal, "default": self.get_default}
|
||||||
"animal": self.get_animal,
|
|
||||||
"default": self.get_default
|
|
||||||
}
|
|
||||||
handler = type_match.get(type_, self.get_default)
|
handler = type_match.get(type_, self.get_default)
|
||||||
return await handler()
|
return await handler()
|
||||||
|
|
||||||
@@ -64,10 +60,9 @@ class AvatarView(BaseView):
|
|||||||
avatar = generate_avatar_with_options(self.request.query)
|
avatar = generate_avatar_with_options(self.request.query)
|
||||||
await self.app.set(key, avatar)
|
await self.app.set(key, avatar)
|
||||||
return web.Response(text=avatar, content_type="image/svg+xml")
|
return web.Response(text=avatar, content_type="image/svg+xml")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get(self, uid):
|
def _get(self, uid):
|
||||||
avatar = generate_avatar_with_options(self.request.query)
|
avatar = generate_avatar_with_options(self.request.query)
|
||||||
return avatar
|
return avatar
|
||||||
|
|||||||
@@ -31,13 +31,12 @@ from multiavatar import multiavatar
|
|||||||
from snek.system.view import BaseView
|
from snek.system.view import BaseView
|
||||||
from snek.view.avatar_animal import generate_avatar_with_options
|
from snek.view.avatar_animal import generate_avatar_with_options
|
||||||
|
|
||||||
import functools
|
|
||||||
|
|
||||||
class AvatarView(BaseView):
|
class AvatarView(BaseView):
|
||||||
login_required = False
|
login_required = False
|
||||||
|
|
||||||
def __init__(self, *args,**kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args,**kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.avatars = {}
|
self.avatars = {}
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
@@ -45,10 +44,9 @@ class AvatarView(BaseView):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return web.Response(text=self._get(uid), content_type="image/svg+xml")
|
return web.Response(text=self._get(uid), content_type="image/svg+xml")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get(self, uid):
|
def _get(self, uid):
|
||||||
if uid in self.avatars:
|
if uid in self.avatars:
|
||||||
return self.avatars[uid]
|
return self.avatars[uid]
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import pathlib
|
||||||
|
import urllib.parse
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
import pillow_heif.HeifImagePlugin
|
|
||||||
|
|
||||||
from snek.system.view import BaseView
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import pathlib
|
from PIL import Image
|
||||||
import urllib.parse
|
from pillow_heif import register_heif_opener
|
||||||
|
|
||||||
|
from snek.system.view import BaseView
|
||||||
|
|
||||||
|
register_heif_opener()
|
||||||
|
|
||||||
class ChannelAttachmentView(BaseView):
|
class ChannelAttachmentView(BaseView):
|
||||||
async def get(self):
|
async def get(self):
|
||||||
@@ -73,18 +75,21 @@ class ChannelAttachmentView(BaseView):
|
|||||||
# response.write is async but image.save is not
|
# response.write is async but image.save is not
|
||||||
naughty_steal = response.write
|
naughty_steal = response.write
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
def sync_writer(*args, **kwargs):
|
def sync_writer(*args, **kwargs):
|
||||||
tasks.append(naughty_steal(*args, **kwargs))
|
tasks.append(naughty_steal(*args, **kwargs))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
setattr(response, "write", sync_writer)
|
setattr(response, "write", sync_writer)
|
||||||
|
|
||||||
image.save(response, format=format_, quality=100, optimize=True, save_all=True)
|
image.save(
|
||||||
|
response, format=format_, quality=100, optimize=True, save_all=True
|
||||||
|
)
|
||||||
|
|
||||||
setattr(response, "write", naughty_steal)
|
setattr(response, "write", naughty_steal)
|
||||||
|
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
response = web.FileResponse(channel_attachment["path"])
|
response = web.FileResponse(channel_attachment["path"])
|
||||||
@@ -127,7 +132,9 @@ class ChannelAttachmentView(BaseView):
|
|||||||
attachment_records = []
|
attachment_records = []
|
||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
attachment_record = attachment.record
|
attachment_record = attachment.record
|
||||||
attachment_record['relative_url'] = urllib.parse.quote(attachment_record['relative_url'])
|
attachment_record["relative_url"] = urllib.parse.quote(
|
||||||
|
attachment_record["relative_url"]
|
||||||
|
)
|
||||||
attachment_records.append(attachment_record)
|
attachment_records.append(attachment_record)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -138,23 +145,37 @@ class ChannelAttachmentView(BaseView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChannelView(BaseView):
|
class ChannelView(BaseView):
|
||||||
async def get(self):
|
async def get(self):
|
||||||
channel_name = self.request.match_info.get("channel")
|
channel_name = self.request.match_info.get("channel")
|
||||||
if(channel_name is None):
|
if channel_name is None:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
channel = await self.services.channel.get(label="#" + channel_name)
|
channel = await self.services.channel.get(label="#" + channel_name)
|
||||||
if(channel is None):
|
if channel is None:
|
||||||
channel = await self.services.channel.get(label=channel_name)
|
channel = await self.services.channel.get(label=channel_name)
|
||||||
channel = await self.services.channel.get(channel_name)
|
channel = await self.services.channel.get(channel_name)
|
||||||
if(channel is None):
|
if channel is None:
|
||||||
channel = await self.services.channel.get(label=channel_name)
|
channel = await self.services.channel.get(label=channel_name)
|
||||||
if(channel is None):
|
if channel is None:
|
||||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||||
is_listed = self.request.query.get("listed", False) == "true"
|
is_listed = self.request.query.get("listed", False) == "true"
|
||||||
is_private = self.request.query.get("private", False) == "true"
|
is_private = self.request.query.get("private", False) == "true"
|
||||||
channel = await self.services.channel.create(label=channel_name,created_by_uid=user['uid'],description="No description provided.",tag="user",is_private=is_private,is_listed=is_listed)
|
channel = await self.services.channel.create(
|
||||||
channel_member = await self.services.channel_member.create(channel_uid=channel['uid'],user_uid=user['uid'],is_moderator=True,is_read_only=False,is_muted=False,is_banned=False)
|
label=channel_name,
|
||||||
|
created_by_uid=user["uid"],
|
||||||
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
|
description="No description provided.",
|
||||||
|
tag="user",
|
||||||
|
is_private=is_private,
|
||||||
|
is_listed=is_listed,
|
||||||
|
)
|
||||||
|
await self.services.channel_member.create(
|
||||||
|
channel_uid=channel["uid"],
|
||||||
|
user_uid=user["uid"],
|
||||||
|
is_moderator=True,
|
||||||
|
is_read_only=False,
|
||||||
|
is_muted=False,
|
||||||
|
is_banned=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from snek.system.view import BaseView
|
||||||
|
import functools
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
class ContainerView(BaseView):
|
||||||
|
|
||||||
|
|
||||||
|
async def stdout_event_handler(self, ws, data):
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
await ws.close()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def create_stdout_event_handler(self, ws):
|
||||||
|
return functools.partial(self.stdout_event_handler, ws)
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(self.request)
|
||||||
|
if not self.request.session.get("logged_in"):
|
||||||
|
return self.HTTPUnauthorized()
|
||||||
|
|
||||||
|
channel_uid = self.request.match_info.get("channel_uid")
|
||||||
|
channel_member = await self.services.channel_member.get(channel_uid=channel_uid, user_uid=self.request.session.get("uid"))
|
||||||
|
if not channel_member:
|
||||||
|
return web.HTTPUnauthorized()
|
||||||
|
|
||||||
|
container = await self.services.container.get(channel_uid)
|
||||||
|
if not container:
|
||||||
|
return web.HTTPNotFound()
|
||||||
|
|
||||||
|
if not container['status'] == 'running':
|
||||||
|
resp = await self.services.container.start(channel_uid)
|
||||||
|
await ws.send_bytes(b'Container is starting\n\n')
|
||||||
|
|
||||||
|
container_name = await self.services.container.get_container_name(channel_uid)
|
||||||
|
|
||||||
|
event_handler = await self.create_stdout_event_handler(ws)
|
||||||
|
|
||||||
|
await self.services.container.add_event_listener(container_name, "stdout", event_handler)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await ws.receive()
|
||||||
|
if data.type == web.WSMsgType.TEXT:
|
||||||
|
|
||||||
|
await self.services.container.write_stdin(container_name, data.data)
|
||||||
|
elif data.type == web.WSMsgType.CLOSE:
|
||||||
|
break
|
||||||
|
elif data.type == web.WSMsgType.ERROR:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.services.container.remove_event_listener(container_name, channel_uid, "stdout")
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.view import BaseView
|
from snek.system.view import BaseView
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
import mimetypes
|
|
||||||
from aiohttp import web
|
|
||||||
from urllib.parse import unquote, quote
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
from pathlib import Path
|
|
||||||
import mimetypes, urllib.parse
|
|
||||||
|
|
||||||
class DriveView(BaseView):
|
class DriveView(BaseView):
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
@@ -27,7 +22,7 @@ class DriveView(BaseView):
|
|||||||
return web.HTTPNotFound(reason="Path not found")
|
return web.HTTPNotFound(reason="Path not found")
|
||||||
|
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
return await self.render_template("drive.html",{"path": rel_path})
|
return await self.render_template("drive.html", {"path": rel_path})
|
||||||
if target.is_file():
|
if target.is_file():
|
||||||
return web.FileResponse(target)
|
return web.FileResponse(target)
|
||||||
|
|
||||||
@@ -37,7 +32,7 @@ class DriveApiView(BaseView):
|
|||||||
target = await self.services.user.get_home_folder(self.session.get("uid"))
|
target = await self.services.user.get_home_folder(self.session.get("uid"))
|
||||||
rel = self.request.query.get("path", "")
|
rel = self.request.query.get("path", "")
|
||||||
offset = int(self.request.query.get("offset", 0))
|
offset = int(self.request.query.get("offset", 0))
|
||||||
limit = int(self.request.query.get("limit", 20))
|
limit = int(self.request.query.get("limit", 20))
|
||||||
|
|
||||||
if rel:
|
if rel:
|
||||||
target = target.joinpath(rel)
|
target = target.joinpath(rel)
|
||||||
@@ -47,58 +42,54 @@ class DriveApiView(BaseView):
|
|||||||
|
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
entries = []
|
entries = []
|
||||||
for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
for p in sorted(
|
||||||
|
target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
|
||||||
|
):
|
||||||
item_path = (Path(rel) / p.name).as_posix()
|
item_path = (Path(rel) / p.name).as_posix()
|
||||||
mime = mimetypes.guess_type(p.name)[0] if p.is_file() else "inode/directory"
|
mime = (
|
||||||
url = (self.request.url.with_path(f"/drive/{urllib.parse.quote(item_path)}")
|
mimetypes.guess_type(p.name)[0]
|
||||||
if p.is_file() else None)
|
if p.is_file()
|
||||||
entries.append({
|
else "inode/directory"
|
||||||
"name": p.name,
|
)
|
||||||
"type": "directory" if p.is_dir() else "file",
|
url = (
|
||||||
"mimetype": mime,
|
self.request.url.with_path(
|
||||||
"size": p.stat().st_size if p.is_file() else None,
|
f"/drive/{urllib.parse.quote(item_path)}"
|
||||||
"path": item_path,
|
)
|
||||||
"url": url,
|
if p.is_file()
|
||||||
})
|
else None
|
||||||
import json
|
)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": p.name,
|
||||||
|
"type": "directory" if p.is_dir() else "file",
|
||||||
|
"mimetype": mime,
|
||||||
|
"size": p.stat().st_size if p.is_file() else None,
|
||||||
|
"path": item_path,
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
import json
|
||||||
|
|
||||||
total = len(entries)
|
total = len(entries)
|
||||||
items = entries[offset:offset+limit]
|
items = entries[offset : offset + limit]
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
"items": json.loads(json.dumps(items,default=str)),
|
{
|
||||||
"pagination": {"offset": offset, "limit": limit, "total": total}
|
"items": json.loads(json.dumps(items, default=str)),
|
||||||
})
|
"pagination": {"offset": offset, "limit": limit, "total": total},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
url = self.request.url.with_path(f"/drive/{urllib.parse.quote(rel)}")
|
url = self.request.url.with_path(f"/drive/{urllib.parse.quote(rel)}")
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
"name": target.name,
|
{
|
||||||
"type": "file",
|
"name": target.name,
|
||||||
"mimetype": mimetypes.guess_type(target.name)[0],
|
"type": "file",
|
||||||
"size": target.stat().st_size,
|
"mimetype": mimetypes.guess_type(target.name)[0],
|
||||||
"path": rel,
|
"size": target.stat().st_size,
|
||||||
"url": str(url),
|
"path": rel,
|
||||||
})
|
"url": str(url),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DriveView222(BaseView):
|
class DriveView222(BaseView):
|
||||||
@@ -124,7 +115,11 @@ class DriveView222(BaseView):
|
|||||||
entry_path = os.path.join(dir_path, entry)
|
entry_path = os.path.join(dir_path, entry)
|
||||||
stat = os.stat(entry_path)
|
stat = os.stat(entry_path)
|
||||||
is_dir = os.path.isdir(entry_path)
|
is_dir = os.path.isdir(entry_path)
|
||||||
mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or "application/octet-stream")
|
mimetype = (
|
||||||
|
None
|
||||||
|
if is_dir
|
||||||
|
else (mimetypes.guess_type(entry_path)[0] or "application/octet-stream")
|
||||||
|
)
|
||||||
size = stat.st_size if not is_dir else None
|
size = stat.st_size if not is_dir else None
|
||||||
created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()
|
created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()
|
||||||
updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||||
@@ -155,15 +150,20 @@ class DriveView222(BaseView):
|
|||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
end = start + page_size
|
end = start + page_size
|
||||||
paged_entries = entries[start:end]
|
paged_entries = entries[start:end]
|
||||||
details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]
|
details = [
|
||||||
return web.json_response({
|
await self.entry_details(full_path, entry, rel_path)
|
||||||
"path": rel_path,
|
for entry in paged_entries
|
||||||
"absolute_url": abs_url,
|
]
|
||||||
"entries": details,
|
return web.json_response(
|
||||||
"total": len(entries),
|
{
|
||||||
"page": page,
|
"path": rel_path,
|
||||||
"page_size": page_size,
|
"absolute_url": abs_url,
|
||||||
})
|
"entries": details,
|
||||||
|
"total": len(entries),
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
with open(full_path, "rb") as f:
|
with open(full_path, "rb") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -180,14 +180,18 @@ class DriveView222(BaseView):
|
|||||||
data = await self.request.post()
|
data = await self.request.post()
|
||||||
if data.get("type") == "dir":
|
if data.get("type") == "dir":
|
||||||
os.makedirs(full_path)
|
os.makedirs(full_path)
|
||||||
return web.json_response({"status": "created", "type": "dir", "absolute_url": abs_url})
|
return web.json_response(
|
||||||
|
{"status": "created", "type": "dir", "absolute_url": abs_url}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
file_field = data.get("file")
|
file_field = data.get("file")
|
||||||
if not file_field:
|
if not file_field:
|
||||||
raise web.HTTPBadRequest(reason="No file uploaded")
|
raise web.HTTPBadRequest(reason="No file uploaded")
|
||||||
with open(full_path, "wb") as f:
|
with open(full_path, "wb") as f:
|
||||||
f.write(file_field.file.read())
|
f.write(file_field.file.read())
|
||||||
return web.json_response({"status": "created", "type": "file", "absolute_url": abs_url})
|
return web.json_response(
|
||||||
|
{"status": "created", "type": "file", "absolute_url": abs_url}
|
||||||
|
)
|
||||||
|
|
||||||
async def put(self):
|
async def put(self):
|
||||||
rel_path = self.request.match_info.get("rel_path", "")
|
rel_path = self.request.match_info.get("rel_path", "")
|
||||||
@@ -210,10 +214,14 @@ class DriveView222(BaseView):
|
|||||||
raise web.HTTPNotFound(reason="Path not found")
|
raise web.HTTPNotFound(reason="Path not found")
|
||||||
if os.path.isdir(full_path):
|
if os.path.isdir(full_path):
|
||||||
os.rmdir(full_path)
|
os.rmdir(full_path)
|
||||||
return web.json_response({"status": "deleted", "type": "dir", "absolute_url": abs_url})
|
return web.json_response(
|
||||||
|
{"status": "deleted", "type": "dir", "absolute_url": abs_url}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
os.remove(full_path)
|
os.remove(full_path)
|
||||||
return web.json_response({"status": "deleted", "type": "file", "absolute_url": abs_url})
|
return web.json_response(
|
||||||
|
{"status": "deleted", "type": "file", "absolute_url": abs_url}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DriveViewi2(BaseView):
|
class DriveViewi2(BaseView):
|
||||||
@@ -223,20 +231,17 @@ class DriveViewi2(BaseView):
|
|||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
drive_uid = self.request.match_info.get("drive")
|
drive_uid = self.request.match_info.get("drive")
|
||||||
|
|
||||||
|
|
||||||
before = self.request.query.get("before")
|
before = self.request.query.get("before")
|
||||||
filters = {}
|
filters = {}
|
||||||
if before:
|
if before:
|
||||||
filters["created_at__lt"] = before
|
filters["created_at__lt"] = before
|
||||||
|
|
||||||
if drive_uid:
|
if drive_uid:
|
||||||
filters['drive_uid'] = drive_uid
|
filters["drive_uid"] = drive_uid
|
||||||
drive = await self.services.drive.get(uid=drive_uid)
|
drive = await self.services.drive.get(uid=drive_uid)
|
||||||
drive_items = []
|
drive_items = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async for item in self.services.drive_item.find(**filters):
|
async for item in self.services.drive_item.find(**filters):
|
||||||
record = item.record
|
record = item.record
|
||||||
record["url"] = "/drive.bin/" + record["uid"] + "." + item.extension
|
record["url"] = "/drive.bin/" + record["uid"] + "." + item.extension
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
from snek.system.view import BaseFormView
|
||||||
|
|
||||||
|
|
||||||
|
class PushView(BaseFormView):
|
||||||
|
async def get(self):
|
||||||
|
return await self.json_response(
|
||||||
|
{
|
||||||
|
"publicKey": base64.b64encode(
|
||||||
|
self.app.services.push.public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.decode("utf-8")
|
||||||
|
.rstrip("="),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post(self):
|
||||||
|
user_id = self.session.get("uid")
|
||||||
|
if user_id:
|
||||||
|
user = await self.app.services.user.get(uid=user_id)
|
||||||
|
if not user:
|
||||||
|
return await self.json_response({"error": "User not found"}, status=404)
|
||||||
|
|
||||||
|
body = await self.request.json()
|
||||||
|
|
||||||
|
if not all(
|
||||||
|
[
|
||||||
|
"endpoint" in body,
|
||||||
|
"keys" in body,
|
||||||
|
"p256dh" in body["keys"],
|
||||||
|
"auth" in body["keys"],
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return await self.json_response(
|
||||||
|
{"error": "Invalid request"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
regist = await self.app.services.push.register(
|
||||||
|
user_uid=user_id,
|
||||||
|
endpoint=body["endpoint"],
|
||||||
|
key_auth=body["keys"]["auth"],
|
||||||
|
key_p256dh=body["keys"]["p256dh"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if regist:
|
||||||
|
test_payload = {
|
||||||
|
"title": f"Welcome {user['nick']}!",
|
||||||
|
"message": "You'll now receive notifications from Snek :D",
|
||||||
|
"icon": "/image/snek192.png",
|
||||||
|
"url": "/web.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
notification_info = (
|
||||||
|
self.app.services.push.create_notification_info_with_payload(
|
||||||
|
body["endpoint"],
|
||||||
|
body["keys"]["auth"],
|
||||||
|
body["keys"]["p256dh"],
|
||||||
|
json.dumps(test_payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
**notification_info["headers"],
|
||||||
|
"TTL": "60",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
body["endpoint"],
|
||||||
|
headers=headers,
|
||||||
|
data=notification_info["data"],
|
||||||
|
) as post_notification:
|
||||||
|
print(post_notification.status)
|
||||||
|
print(post_notification.text)
|
||||||
|
print(post_notification.headers)
|
||||||
|
|
||||||
|
return await self.json_response({"registered": True})
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import os
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import humanize
|
import humanize
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.view import BaseView
|
from snek.system.view import BaseView
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BareRepoNavigator:
|
class BareRepoNavigator:
|
||||||
@@ -25,18 +24,18 @@ class BareRepoNavigator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error opening repository: {str(e)}")
|
print(f"Error opening repository: {str(e)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
self.repo_path = repo_path
|
self.repo_path = repo_path
|
||||||
self.branches = list(self.repo.branches)
|
self.branches = list(self.repo.branches)
|
||||||
self.current_branch = None
|
self.current_branch = None
|
||||||
self.current_commit = None
|
self.current_commit = None
|
||||||
self.current_path = ""
|
self.current_path = ""
|
||||||
self.history = []
|
self.history = []
|
||||||
|
|
||||||
def get_branches(self):
|
def get_branches(self):
|
||||||
"""Return a list of branch names in the repository."""
|
"""Return a list of branch names in the repository."""
|
||||||
return [branch.name for branch in self.branches]
|
return [branch.name for branch in self.branches]
|
||||||
|
|
||||||
def set_branch(self, branch_name):
|
def set_branch(self, branch_name):
|
||||||
"""Set the current branch."""
|
"""Set the current branch."""
|
||||||
try:
|
try:
|
||||||
@@ -47,23 +46,27 @@ class BareRepoNavigator:
|
|||||||
return True
|
return True
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_commits(self, count=10):
|
def get_commits(self, count=10):
|
||||||
"""Get the latest commits on the current branch."""
|
"""Get the latest commits on the current branch."""
|
||||||
if not self.current_branch:
|
if not self.current_branch:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
commits = []
|
commits = []
|
||||||
for commit in self.repo.iter_commits(self.current_branch, max_count=count):
|
for commit in self.repo.iter_commits(self.current_branch, max_count=count):
|
||||||
commits.append({
|
commits.append(
|
||||||
'hash': commit.hexsha,
|
{
|
||||||
'short_hash': commit.hexsha[:7],
|
"hash": commit.hexsha,
|
||||||
'message': commit.message.strip(),
|
"short_hash": commit.hexsha[:7],
|
||||||
'author': commit.author.name,
|
"message": commit.message.strip(),
|
||||||
'date': datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M:%S')
|
"author": commit.author.name,
|
||||||
})
|
"date": datetime.fromtimestamp(commit.committed_date).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
return commits
|
return commits
|
||||||
|
|
||||||
def set_commit(self, commit_hash):
|
def set_commit(self, commit_hash):
|
||||||
"""Set the current commit by hash."""
|
"""Set the current commit by hash."""
|
||||||
try:
|
try:
|
||||||
@@ -73,48 +76,48 @@ class BareRepoNavigator:
|
|||||||
return True
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def list_directory(self, path=""):
|
def list_directory(self, path=""):
|
||||||
"""List the contents of a directory in the current commit."""
|
"""List the contents of a directory in the current commit."""
|
||||||
if not self.current_commit:
|
if not self.current_commit:
|
||||||
return {'dirs': [], 'files': []}
|
return {"dirs": [], "files": []}
|
||||||
|
|
||||||
dirs = []
|
dirs = []
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the tree at the current path
|
# Get the tree at the current path
|
||||||
if path:
|
if path:
|
||||||
tree = self.current_commit.tree[path]
|
tree = self.current_commit.tree[path]
|
||||||
if not hasattr(tree, 'trees'): # It's a blob, not a tree
|
if not hasattr(tree, "trees"): # It's a blob, not a tree
|
||||||
return {'dirs': [], 'files': [path]}
|
return {"dirs": [], "files": [path]}
|
||||||
else:
|
else:
|
||||||
tree = self.current_commit.tree
|
tree = self.current_commit.tree
|
||||||
|
|
||||||
# List directories and files
|
# List directories and files
|
||||||
for item in tree:
|
for item in tree:
|
||||||
if item.type == 'tree':
|
if item.type == "tree":
|
||||||
item_path = os.path.join(path, item.name) if path else item.name
|
item_path = os.path.join(path, item.name) if path else item.name
|
||||||
dirs.append(item_path)
|
dirs.append(item_path)
|
||||||
elif item.type == 'blob':
|
elif item.type == "blob":
|
||||||
item_path = os.path.join(path, item.name) if path else item.name
|
item_path = os.path.join(path, item.name) if path else item.name
|
||||||
files.append(item_path)
|
files.append(item_path)
|
||||||
|
|
||||||
dirs.sort()
|
dirs.sort()
|
||||||
files.sort()
|
files.sort()
|
||||||
return {'dirs': dirs, 'files': files}
|
return {"dirs": dirs, "files": files}
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return {'dirs': [], 'files': []}
|
return {"dirs": [], "files": []}
|
||||||
|
|
||||||
def get_file_content(self, file_path):
|
def get_file_content(self, file_path):
|
||||||
"""Get the content of a file in the current commit."""
|
"""Get the content of a file in the current commit."""
|
||||||
if not self.current_commit:
|
if not self.current_commit:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blob = self.current_commit.tree[file_path]
|
blob = self.current_commit.tree[file_path]
|
||||||
return blob.data_stream.read().decode('utf-8', errors='replace')
|
return blob.data_stream.read().decode("utf-8", errors="replace")
|
||||||
except (KeyError, UnicodeDecodeError):
|
except (KeyError, UnicodeDecodeError):
|
||||||
try:
|
try:
|
||||||
# Try to get as binary if text decoding fails
|
# Try to get as binary if text decoding fails
|
||||||
@@ -122,12 +125,12 @@ class BareRepoNavigator:
|
|||||||
return blob.data_stream.read()
|
return blob.data_stream.read()
|
||||||
except:
|
except:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def navigate_to(self, path):
|
def navigate_to(self, path):
|
||||||
"""Navigate to a specific path, updating the current path."""
|
"""Navigate to a specific path, updating the current path."""
|
||||||
if not self.current_commit:
|
if not self.current_commit:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if path:
|
if path:
|
||||||
self.current_commit.tree[path] # Check if path exists
|
self.current_commit.tree[path] # Check if path exists
|
||||||
@@ -136,7 +139,7 @@ class BareRepoNavigator:
|
|||||||
return True
|
return True
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def navigate_back(self):
|
def navigate_back(self):
|
||||||
"""Navigate back to the previous path."""
|
"""Navigate back to the previous path."""
|
||||||
if self.history:
|
if self.history:
|
||||||
@@ -145,12 +148,13 @@ class BareRepoNavigator:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class RepositoryView(BaseView):
|
class RepositoryView(BaseView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
def checkout_bare_repo(self, bare_repo_path: Path, target_path: Path, ref: str = 'HEAD'):
|
def checkout_bare_repo(
|
||||||
|
self, bare_repo_path: Path, target_path: Path, ref: str = "HEAD"
|
||||||
|
):
|
||||||
repo = Repo(bare_repo_path)
|
repo = Repo(bare_repo_path)
|
||||||
assert repo.bare, "Repository is not bare."
|
assert repo.bare, "Repository is not bare."
|
||||||
|
|
||||||
@@ -159,23 +163,22 @@ class RepositoryView(BaseView):
|
|||||||
|
|
||||||
for blob in tree.traverse():
|
for blob in tree.traverse():
|
||||||
target_file = target_path / blob.path
|
target_file = target_path / blob.path
|
||||||
|
|
||||||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
print(blob.path)
|
print(blob.path)
|
||||||
|
|
||||||
with open(target_file, 'wb') as f:
|
with open(target_file, "wb") as f:
|
||||||
f.write(blob.data_stream.read())
|
f.write(blob.data_stream.read())
|
||||||
|
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
base_repo_path = Path("drive/repositories")
|
base_repo_path = Path("drive/repositories")
|
||||||
|
|
||||||
authenticated_user_id = self.session.get("uid")
|
authenticated_user_id = self.session.get("uid")
|
||||||
|
|
||||||
username = self.request.match_info.get('username')
|
username = self.request.match_info.get("username")
|
||||||
repo_name = self.request.match_info.get('repository')
|
repo_name = self.request.match_info.get("repository")
|
||||||
rel_path = self.request.match_info.get('path', '')
|
rel_path = self.request.match_info.get("path", "")
|
||||||
user = None
|
user = None
|
||||||
if not username.count("-") == 4:
|
if not username.count("-") == 4:
|
||||||
user = await self.app.services.user.get(username=username)
|
user = await self.app.services.user.get(username=username)
|
||||||
@@ -185,22 +188,24 @@ class RepositoryView(BaseView):
|
|||||||
else:
|
else:
|
||||||
user = await self.app.services.user.get(uid=username)
|
user = await self.app.services.user.get(uid=username)
|
||||||
|
|
||||||
repo = await self.app.services.repository.get(name=repo_name, user_uid=user["uid"])
|
repo = await self.app.services.repository.get(
|
||||||
|
name=repo_name, user_uid=user["uid"]
|
||||||
|
)
|
||||||
if not repo:
|
if not repo:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
return web.Response(text="404 Not Found", status=404)
|
||||||
if repo['is_private'] and authenticated_user_id != repo['uid']:
|
if repo["is_private"] and authenticated_user_id != repo["uid"]:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
return web.Response(text="404 Not Found", status=404)
|
||||||
|
|
||||||
repo_root_base = (base_repo_path / user['uid'] / (repo_name + ".git")).resolve()
|
repo_root_base = (base_repo_path / user["uid"] / (repo_name + ".git")).resolve()
|
||||||
repo_root = (base_repo_path / user['uid'] / repo_name).resolve()
|
repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None,
|
await loop.run_in_executor(
|
||||||
self.checkout_bare_repo, repo_root_base, repo_root
|
None, self.checkout_bare_repo, repo_root_base, repo_root
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not repo_root.exists() or not repo_root.is_dir():
|
if not repo_root.exists() or not repo_root.is_dir():
|
||||||
return web.Response(text="404 Not Found", status=404)
|
return web.Response(text="404 Not Found", status=404)
|
||||||
|
|
||||||
@@ -211,24 +216,35 @@ class RepositoryView(BaseView):
|
|||||||
return web.Response(text="404 Not Found", status=404)
|
return web.Response(text="404 Not Found", status=404)
|
||||||
|
|
||||||
if abs_path.is_dir():
|
if abs_path.is_dir():
|
||||||
return web.Response(text=self.render_directory(abs_path, username, repo_name, safe_rel_path), content_type='text/html')
|
return web.Response(
|
||||||
|
text=self.render_directory(
|
||||||
|
abs_path, username, repo_name, safe_rel_path
|
||||||
|
),
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return web.Response(text=self.render_file(abs_path), content_type='text/html')
|
return web.Response(
|
||||||
|
text=self.render_file(abs_path), content_type="text/html"
|
||||||
|
)
|
||||||
|
|
||||||
def render_directory(self, abs_path, username, repo_name, safe_rel_path):
|
def render_directory(self, abs_path, username, repo_name, safe_rel_path):
|
||||||
entries = sorted(abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
entries = sorted(
|
||||||
|
abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
|
||||||
|
)
|
||||||
items = []
|
items = []
|
||||||
|
|
||||||
if safe_rel_path:
|
if safe_rel_path:
|
||||||
parent_path = Path(safe_rel_path).parent
|
parent_path = Path(safe_rel_path).parent
|
||||||
parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip('/')
|
parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
items.append(f'<li><a href="{parent_link}">โฌ
๏ธ ..</a></li>')
|
items.append(f'<li><a href="{parent_link}">โฌ
๏ธ ..</a></li>')
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
|
link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
|
||||||
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip('/')
|
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip("/")
|
||||||
display = entry.name + ('/' if entry.is_dir() else '')
|
display = entry.name + ("/" if entry.is_dir() else "")
|
||||||
size = '' if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
|
size = "" if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
|
||||||
icon = self.get_icon(entry)
|
icon = self.get_icon(entry)
|
||||||
items.append(f'<li>{icon} <a href="{link}">{display}</a> {size}</li>')
|
items.append(f'<li>{icon} <a href="{link}">{display}</a> {size}</li>')
|
||||||
|
|
||||||
@@ -247,19 +263,24 @@ class RepositoryView(BaseView):
|
|||||||
|
|
||||||
def render_file(self, abs_path):
|
def render_file(self, abs_path):
|
||||||
try:
|
try:
|
||||||
with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:
|
with open(abs_path, encoding="utf-8", errors="ignore") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return f"<pre>{content}</pre>"
|
return f"<pre>{content}</pre>"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"<h1>Error</h1><pre>{e}</pre>"
|
return f"<h1>Error</h1><pre>{e}</pre>"
|
||||||
|
|
||||||
def get_icon(self, file):
|
def get_icon(self, file):
|
||||||
if file.is_dir(): return "๐"
|
if file.is_dir():
|
||||||
mime = mimetypes.guess_type(file.name)[0] or ''
|
return "๐"
|
||||||
if mime.startswith("image"): return "๐ผ๏ธ"
|
mime = mimetypes.guess_type(file.name)[0] or ""
|
||||||
if mime.startswith("text"): return "๐"
|
if mime.startswith("image"):
|
||||||
if mime.startswith("audio"): return "๐ต"
|
return "๐ผ๏ธ"
|
||||||
if mime.startswith("video"): return "๐ฌ"
|
if mime.startswith("text"):
|
||||||
if file.name.endswith(".py"): return "๐"
|
return "๐"
|
||||||
|
if mime.startswith("audio"):
|
||||||
|
return "๐ต"
|
||||||
|
if mime.startswith("video"):
|
||||||
|
return "๐ฌ"
|
||||||
|
if file.name.endswith(".py"):
|
||||||
|
return "๐"
|
||||||
return "๐ฆ"
|
return "๐ฆ"
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,20 @@
|
|||||||
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
||||||
|
|
||||||
|
|
||||||
import json
|
|
||||||
import traceback
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
from snek.system.profiler import Profiler
|
from snek.system.profiler import Profiler
|
||||||
from snek.system.view import BaseView
|
from snek.system.view import BaseView
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RPCView(BaseView):
|
class RPCView(BaseView):
|
||||||
class RPCApi:
|
class RPCApi:
|
||||||
def __init__(self, view, ws):
|
def __init__(self, view, ws):
|
||||||
@@ -32,7 +33,7 @@ class RPCView(BaseView):
|
|||||||
|
|
||||||
async def _session_ensure(self):
|
async def _session_ensure(self):
|
||||||
uid = await self.view.session_get("uid")
|
uid = await self.view.session_get("uid")
|
||||||
if not uid in self.user_session:
|
if uid not in self.user_session:
|
||||||
self.user_session[uid] = {
|
self.user_session[uid] = {
|
||||||
"said_hello": False,
|
"said_hello": False,
|
||||||
}
|
}
|
||||||
@@ -50,45 +51,54 @@ class RPCView(BaseView):
|
|||||||
self._require_login()
|
self._require_login()
|
||||||
|
|
||||||
return await self.services.db.insert(self.user_uid, table_name, record)
|
return await self.services.db.insert(self.user_uid, table_name, record)
|
||||||
|
|
||||||
async def db_update(self, table_name, record):
|
async def db_update(self, table_name, record):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.update(self.user_uid, table_name, record)
|
return await self.services.db.update(self.user_uid, table_name, record)
|
||||||
async def set_typing(self,channel_uid,color=None):
|
|
||||||
|
async def set_typing(self, channel_uid, color=None):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
user = await self.services.user.get(self.user_uid)
|
user = await self.services.user.get(self.user_uid)
|
||||||
if not color:
|
if not color:
|
||||||
color = user["color"]
|
color = user["color"]
|
||||||
return await self.services.socket.broadcast(channel_uid, {
|
return await self.services.socket.broadcast(
|
||||||
"channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2",
|
channel_uid,
|
||||||
"event": "set_typing",
|
{
|
||||||
"data": {
|
"channel_uid": "293ecf12-08c9-494b-b423-48ba1a2d12c2",
|
||||||
"event":"set_typing",
|
"event": "set_typing",
|
||||||
"user_uid": user['uid'],
|
"data": {
|
||||||
"username": user["username"],
|
"event": "set_typing",
|
||||||
"nick": user["nick"],
|
"user_uid": user["uid"],
|
||||||
"channel_uid": channel_uid,
|
"username": user["username"],
|
||||||
"color": color
|
"nick": user["nick"],
|
||||||
}
|
"channel_uid": channel_uid,
|
||||||
})
|
"color": color,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def db_delete(self, table_name, record):
|
async def db_delete(self, table_name, record):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.delete(self.user_uid, table_name, record)
|
return await self.services.db.delete(self.user_uid, table_name, record)
|
||||||
|
|
||||||
async def db_get(self, table_name, record):
|
async def db_get(self, table_name, record):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.get(self.user_uid, table_name, record)
|
return await self.services.db.get(self.user_uid, table_name, record)
|
||||||
|
|
||||||
async def db_find(self, table_name, record):
|
async def db_find(self, table_name, record):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.find(self.user_uid, table_name, record)
|
return await self.services.db.find(self.user_uid, table_name, record)
|
||||||
async def db_upsert(self, table_name, record,keys):
|
|
||||||
|
async def db_upsert(self, table_name, record, keys):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.upsert(self.user_uid, table_name, record,keys)
|
return await self.services.db.upsert(
|
||||||
|
self.user_uid, table_name, record, keys
|
||||||
|
)
|
||||||
|
|
||||||
async def db_query(self, table_name, args):
|
async def db_query(self, table_name, args):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
return await self.services.db.query(self.user_uid, table_name, sql, args)
|
return await self.services.db.query(self.user_uid, table_name, sql, args)
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_uid(self):
|
def user_uid(self):
|
||||||
return self.view.session.get("uid")
|
return self.view.session.get("uid")
|
||||||
@@ -192,55 +202,123 @@ class RPCView(BaseView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
|
async def write_container(self, channel_uid, content):
|
||||||
|
self._require_login()
|
||||||
|
channel_member = await self.services.channel_member.get(
|
||||||
|
channel_uid=channel_uid, user_uid=self.user_uid
|
||||||
|
)
|
||||||
|
if not channel_member:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
|
||||||
|
container_name = await self.services.container.get_container_name(channel_uid)
|
||||||
|
await self.services.container.write_stdin(channel_uid, content)
|
||||||
|
|
||||||
|
return "Written to terminal, response of terminal is not implemented yet."
|
||||||
|
|
||||||
|
async def get_container(self, channel_uid):
|
||||||
|
self._require_login()
|
||||||
|
channel_member = await self.services.channel_member.get(
|
||||||
|
channel_uid=channel_uid, user_uid=self.user_uid
|
||||||
|
)
|
||||||
|
if not channel_member:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
container = await self.services.container.get(channel_uid)
|
||||||
|
result = None
|
||||||
|
if container:
|
||||||
|
result = {
|
||||||
|
"name": await self.services.container.get_container_name(channel_uid),
|
||||||
|
"cpus": container["deploy"]["resources"]["limits"]["cpus"],
|
||||||
|
"memory": container["deploy"]["resources"]["limits"]["memory"],
|
||||||
|
"image": "ubuntu:latest",
|
||||||
|
"volumes": [],
|
||||||
|
"status": container["status"]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def start_container(self, channel_uid):
|
||||||
|
self._require_login()
|
||||||
|
channel_member = await self.services.channel_member.get(
|
||||||
|
channel_uid=channel_uid, user_uid=self.user_uid
|
||||||
|
)
|
||||||
|
if not channel_member:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
return await self.services.container.start(channel_uid)
|
||||||
|
|
||||||
|
async def stop_container(self, channel_uid):
|
||||||
|
self._require_login()
|
||||||
|
channel_member = await self.services.channel_member.get(
|
||||||
|
channel_uid=channel_uid, user_uid=self.user_uid
|
||||||
|
)
|
||||||
|
if not channel_member:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
return await self.services.container.stop(channel_uid)
|
||||||
|
|
||||||
|
async def get_container_status(self, channel_uid):
|
||||||
|
self._require_login()
|
||||||
|
channel_member = await self.services.channel_member.get(
|
||||||
|
channel_uid=channel_uid, user_uid=self.user_uid
|
||||||
|
)
|
||||||
|
if not channel_member:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
return await self.services.container.get_status(channel_uid)
|
||||||
|
|
||||||
async def finalize_message(self, message_uid):
|
async def finalize_message(self, message_uid):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
message = await self.services.channel_message.get(message_uid)
|
message = await self.services.channel_message.get(message_uid)
|
||||||
if not message:
|
if not message:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if message["user_uid"] != self.user_uid:
|
if message["user_uid"] != self.user_uid:
|
||||||
raise Exception("Not allowed")
|
raise Exception("Not allowed")
|
||||||
|
|
||||||
|
if not message["is_final"]:
|
||||||
if not message['is_final']:
|
await self.services.chat.finalize(message["uid"])
|
||||||
await self.services.chat.finalize(message['uid'])
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def update_message_text(self,message_uid, text):
|
async def update_message_text(self, message_uid, text):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
message = await self.services.channel_message.get(message_uid)
|
message = await self.services.channel_message.get(message_uid)
|
||||||
if message["user_uid"] != self.user_uid:
|
if message["user_uid"] != self.user_uid:
|
||||||
raise Exception("Not allowed")
|
raise Exception("Not allowed")
|
||||||
|
|
||||||
if message.get_seconds_since_last_update() > 3:
|
if message.get_seconds_since_last_update() > 3:
|
||||||
await self.finalize_message(message["uid"])
|
await self.finalize_message(message["uid"])
|
||||||
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
|
return {
|
||||||
|
"error": "Message too old",
|
||||||
|
"seconds_since_last_update": message.get_seconds_since_last_update(),
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
message['message'] = text
|
message["message"] = text
|
||||||
if not text:
|
if not text:
|
||||||
message['deleted_at'] = now()
|
message["deleted_at"] = now()
|
||||||
else:
|
else:
|
||||||
message['deleted_at'] = None
|
message["deleted_at"] = None
|
||||||
|
|
||||||
await self.services.channel_message.save(message)
|
await self.services.channel_message.save(message)
|
||||||
data = message.record
|
data = message.record
|
||||||
data['text'] = message["message"]
|
data["text"] = message["message"]
|
||||||
data['message_uid'] = message_uid
|
data["message_uid"] = message_uid
|
||||||
|
|
||||||
|
await self.services.socket.broadcast(
|
||||||
|
message["channel_uid"],
|
||||||
|
{
|
||||||
|
"channel_uid": message["channel_uid"],
|
||||||
|
"event": "update_message_text",
|
||||||
|
"data": message.record,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await self.services.socket.broadcast(message["channel_uid"], {
|
|
||||||
"channel_uid": message["channel_uid"],
|
|
||||||
"event": "update_message_text",
|
|
||||||
"data": message.record
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
async def send_message(self, channel_uid, message,is_final=True):
|
async def send_message(self, channel_uid, message, is_final=True):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
message = await self.services.chat.send(self.user_uid, channel_uid, message,is_final)
|
message = await self.services.chat.send(
|
||||||
|
self.user_uid, channel_uid, message, is_final
|
||||||
|
)
|
||||||
|
|
||||||
return message["uid"]
|
return message["uid"]
|
||||||
|
|
||||||
async def echo(self, *args):
|
async def echo(self, *args):
|
||||||
@@ -324,13 +402,15 @@ class RPCView(BaseView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _send_json(self, obj):
|
async def _send_json(self, obj):
|
||||||
await self.ws.send_str(json.dumps(obj, default=str))
|
if not self.ws.closed:
|
||||||
|
await self.ws.send_str(json.dumps(obj, default=str))
|
||||||
|
|
||||||
async def get_online_users(self, channel_uid):
|
async def get_online_users(self, channel_uid):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
record async for record in self.services.channel.get_recent_users(channel_uid)
|
record
|
||||||
|
async for record in self.services.channel.get_recent_users(channel_uid)
|
||||||
]
|
]
|
||||||
results = sorted(results, key=lambda x: x["nick"])
|
results = sorted(results, key=lambda x: x["nick"])
|
||||||
return results
|
return results
|
||||||
@@ -371,7 +451,6 @@ class RPCView(BaseView):
|
|||||||
await self.services.socket.send_to_user(self.user_uid, call)
|
await self.services.socket.send_to_user(self.user_uid, call)
|
||||||
self._scheduled.remove(call)
|
self._scheduled.remove(call)
|
||||||
|
|
||||||
|
|
||||||
async def ping(self, callId, *args):
|
async def ping(self, callId, *args):
|
||||||
if self.user_uid:
|
if self.user_uid:
|
||||||
user = await self.services.user.get(uid=self.user_uid)
|
user = await self.services.user.get(uid=self.user_uid)
|
||||||
@@ -379,17 +458,23 @@ class RPCView(BaseView):
|
|||||||
await self.services.user.save(user)
|
await self.services.user.save(user)
|
||||||
return {"pong": args}
|
return {"pong": args}
|
||||||
|
|
||||||
|
|
||||||
async def stars_render(self, channel_uid, message):
|
async def stars_render(self, channel_uid, message):
|
||||||
|
|
||||||
for user in await self.get_online_users(channel_uid):
|
for user in await self.get_online_users(channel_uid):
|
||||||
try:
|
try:
|
||||||
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
|
await self.services.socket.send_to_user(
|
||||||
|
user["uid"],
|
||||||
|
{
|
||||||
|
"event": "stars_render",
|
||||||
|
"data": {"channel_uid": channel_uid, "message": message},
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex)
|
print(ex)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
scheduled = []
|
scheduled = []
|
||||||
|
|
||||||
async def schedule(uid, seconds, call):
|
async def schedule(uid, seconds, call):
|
||||||
scheduled.append(call)
|
scheduled.append(call)
|
||||||
await asyncio.sleep(seconds)
|
await asyncio.sleep(seconds)
|
||||||
@@ -408,16 +493,18 @@ class RPCView(BaseView):
|
|||||||
await self.services.socket.subscribe(
|
await self.services.socket.subscribe(
|
||||||
ws, subscription["channel_uid"], self.request.session.get("uid")
|
ws, subscription["channel_uid"], self.request.session.get("uid")
|
||||||
)
|
)
|
||||||
if not scheduled and self.request.app.uptime_seconds < 5:
|
if not scheduled and self.request.app.uptime_seconds < 5:
|
||||||
await schedule(self.request.session.get("uid"),0,{"event":"refresh", "data": {
|
await schedule(
|
||||||
"message": "Finishing deployment"}
|
self.request.session.get("uid"),
|
||||||
}
|
0,
|
||||||
)
|
{"event": "refresh", "data": {"message": "Finishing deployment"}},
|
||||||
await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
|
|
||||||
"uptime": self.request.app.uptime}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
await schedule(
|
||||||
|
self.request.session.get("uid"),
|
||||||
|
15,
|
||||||
|
{"event": "deployed", "data": {"uptime": self.request.app.uptime}},
|
||||||
|
)
|
||||||
|
|
||||||
rpc = RPCView.RPCApi(self, ws)
|
rpc = RPCView.RPCApi(self, ws)
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
if msg.type == web.WSMsgType.TEXT:
|
if msg.type == web.WSMsgType.TEXT:
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
import asyncio
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.view import BaseFormView
|
from snek.system.view import BaseFormView
|
||||||
import pathlib
|
|
||||||
|
|
||||||
class ContainersIndexView(BaseFormView):
|
class ContainersIndexView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
user_uid = self.session.get("uid")
|
user_uid = self.session.get("uid")
|
||||||
|
|
||||||
containers = []
|
containers = []
|
||||||
async for container in self.services.container.find(user_uid=user_uid):
|
async for container in self.services.container.find(user_uid=user_uid):
|
||||||
containers.append(container.record)
|
containers.append(container.record)
|
||||||
|
|
||||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||||
|
|
||||||
return await self.render_template("settings/containers/index.html", {"containers": containers, "user": user})
|
return await self.render_template(
|
||||||
|
"settings/containers/index.html", {"containers": containers, "user": user}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContainersCreateView(BaseFormView):
|
class ContainersCreateView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
return await self.render_template("settings/containers/create.html")
|
return await self.render_template("settings/containers/create.html")
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
data = await self.request.post()
|
data = await self.request.post()
|
||||||
container = await self.services.container.create(
|
await self.services.container.create(
|
||||||
user_uid=self.session.get("uid"),
|
user_uid=self.session.get("uid"),
|
||||||
name=data['name'],
|
name=data["name"],
|
||||||
status=data['status'],
|
status=data["status"],
|
||||||
resources=data.get('resources', ''),
|
resources=data.get("resources", ""),
|
||||||
path=data.get('path', ''),
|
path=data.get("path", ""),
|
||||||
readonly=bool(data.get('readonly', False))
|
readonly=bool(data.get("readonly", False)),
|
||||||
)
|
)
|
||||||
return web.HTTPFound("/settings/containers/index.html")
|
return web.HTTPFound("/settings/containers/index.html")
|
||||||
|
|
||||||
|
|
||||||
class ContainersUpdateView(BaseFormView):
|
class ContainersUpdateView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
@@ -51,40 +54,43 @@ class ContainersUpdateView(BaseFormView):
|
|||||||
)
|
)
|
||||||
if not container:
|
if not container:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
return await self.render_template("settings/containers/update.html", {"container": container.record})
|
return await self.render_template(
|
||||||
|
"settings/containers/update.html", {"container": container.record}
|
||||||
|
)
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
data = await self.request.post()
|
data = await self.request.post()
|
||||||
container = await self.services.container.get(
|
container = await self.services.container.get(
|
||||||
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
||||||
)
|
)
|
||||||
container['status'] = data['status']
|
container["status"] = data["status"]
|
||||||
container['resources'] = data.get('resources', '')
|
container["resources"] = data.get("resources", "")
|
||||||
container['path'] = data.get('path', '')
|
container["path"] = data.get("path", "")
|
||||||
container['readonly'] = bool(data.get('readonly', False))
|
container["readonly"] = bool(data.get("readonly", False))
|
||||||
await self.services.container.save(container)
|
await self.services.container.save(container)
|
||||||
return web.HTTPFound("/settings/containers/index.html")
|
return web.HTTPFound("/settings/containers/index.html")
|
||||||
|
|
||||||
|
|
||||||
class ContainersDeleteView(BaseFormView):
|
class ContainersDeleteView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
container = await self.services.container.get(
|
container = await self.services.container.get(
|
||||||
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
user_uid=self.session.get("uid"), uid=self.request.match_info["uid"]
|
||||||
)
|
)
|
||||||
if not container:
|
if not container:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
|
|
||||||
return await self.render_template("settings/containers/delete.html", {"container": container.record})
|
return await self.render_template(
|
||||||
|
"settings/containers/delete.html", {"container": container.record}
|
||||||
|
)
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
user_uid = self.session.get("uid")
|
user_uid = self.session.get("uid")
|
||||||
uid = self.request.match_info["uid"]
|
uid = self.request.match_info["uid"]
|
||||||
container = await self.services.container.get(
|
container = await self.services.container.get(user_uid=user_uid, uid=uid)
|
||||||
user_uid=user_uid, uid=uid
|
|
||||||
)
|
|
||||||
if not container:
|
if not container:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
await self.services.container.delete(user_uid=user_uid, uid=uid)
|
await self.services.container.delete(user_uid=user_uid, uid=uid)
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import asyncio
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.view import BaseFormView
|
from snek.system.view import BaseFormView
|
||||||
import pathlib
|
|
||||||
|
|
||||||
class RepositoriesIndexView(BaseFormView):
|
class RepositoriesIndexView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
user_uid = self.session.get("uid")
|
user_uid = self.session.get("uid")
|
||||||
|
|
||||||
repositories = []
|
repositories = []
|
||||||
async for repository in self.services.repository.find(user_uid=user_uid):
|
async for repository in self.services.repository.find(user_uid=user_uid):
|
||||||
repositories.append(repository.record)
|
repositories.append(repository.record)
|
||||||
|
|
||||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||||
|
|
||||||
return await self.render_template("settings/repositories/index.html", {"repositories": repositories, "user": user})
|
return await self.render_template(
|
||||||
|
"settings/repositories/index.html",
|
||||||
|
{"repositories": repositories, "user": user},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RepositoriesCreateView(BaseFormView):
|
class RepositoriesCreateView(BaseFormView):
|
||||||
@@ -28,14 +28,19 @@ class RepositoriesCreateView(BaseFormView):
|
|||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
return await self.render_template("settings/repositories/create.html")
|
return await self.render_template("settings/repositories/create.html")
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
data = await self.request.post()
|
data = await self.request.post()
|
||||||
repository = await self.services.repository.create(user_uid=self.session.get("uid"), name=data['name'], is_private=int(data.get('is_private',0)))
|
await self.services.repository.create(
|
||||||
|
user_uid=self.session.get("uid"),
|
||||||
|
name=data["name"],
|
||||||
|
is_private=int(data.get("is_private", 0)),
|
||||||
|
)
|
||||||
return web.HTTPFound("/settings/repositories/index.html")
|
return web.HTTPFound("/settings/repositories/index.html")
|
||||||
|
|
||||||
|
|
||||||
class RepositoriesUpdateView(BaseFormView):
|
class RepositoriesUpdateView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
@@ -47,40 +52,41 @@ class RepositoriesUpdateView(BaseFormView):
|
|||||||
)
|
)
|
||||||
if not repository:
|
if not repository:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
return await self.render_template("settings/repositories/update.html", {"repository": repository.record})
|
return await self.render_template(
|
||||||
|
"settings/repositories/update.html", {"repository": repository.record}
|
||||||
|
)
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
data = await self.request.post()
|
data = await self.request.post()
|
||||||
repository = await self.services.repository.get(
|
repository = await self.services.repository.get(
|
||||||
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
||||||
)
|
)
|
||||||
repository['is_private'] = int(data.get('is_private',0))
|
repository["is_private"] = int(data.get("is_private", 0))
|
||||||
await self.services.repository.save(repository)
|
await self.services.repository.save(repository)
|
||||||
return web.HTTPFound("/settings/repositories/index.html")
|
return web.HTTPFound("/settings/repositories/index.html")
|
||||||
|
|
||||||
|
|
||||||
class RepositoriesDeleteView(BaseFormView):
|
class RepositoriesDeleteView(BaseFormView):
|
||||||
|
|
||||||
login_required = True
|
login_required = True
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
|
|
||||||
repository = await self.services.repository.get(
|
repository = await self.services.repository.get(
|
||||||
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
||||||
)
|
)
|
||||||
if not repository:
|
if not repository:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
|
|
||||||
return await self.render_template("settings/repositories/delete.html", {"repository": repository.record})
|
return await self.render_template(
|
||||||
|
"settings/repositories/delete.html", {"repository": repository.record}
|
||||||
|
)
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
user_uid = self.session.get("uid")
|
user_uid = self.session.get("uid")
|
||||||
name = self.request.match_info["name"]
|
name = self.request.match_info["name"]
|
||||||
repository = await self.services.repository.get(
|
repository = await self.services.repository.get(user_uid=user_uid, name=name)
|
||||||
user_uid=user_uid, name=name
|
|
||||||
)
|
|
||||||
if not repository:
|
if not repository:
|
||||||
return web.HTTPNotFound()
|
return web.HTTPNotFound()
|
||||||
await self.services.repository.delete(user_uid=user_uid, name=name)
|
await self.services.repository.delete(user_uid=user_uid, name=name)
|
||||||
return web.HTTPFound("/settings/repositories/index.html")
|
return web.HTTPFound("/settings/repositories/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
๐ Welcome to your custom Ubuntu development environment!
|
||||||
|
|
||||||
|
This container is pre-configured with a rich set of tools to help you hit the ground running:
|
||||||
|
|
||||||
|
๐ง Development Libraries & Tools:
|
||||||
|
|
||||||
|
Full support for building and debugging C/C++ and Python code
|
||||||
|
|
||||||
|
Libraries for SSL, readline, SQLite, zlib, bz2, ffi, lzma, and JSON-C
|
||||||
|
|
||||||
|
Valgrind for memory debugging and profiling
|
||||||
|
|
||||||
|
๐ Python:
|
||||||
|
|
||||||
|
Python 3 with pip and virtual environment support (venv)
|
||||||
|
|
||||||
|
๐ฆ Rust:
|
||||||
|
|
||||||
|
Rust installed with the nightly toolchain via rustup
|
||||||
|
|
||||||
|
๐ Command-line Essentials:
|
||||||
|
|
||||||
|
vim, tmux, htop, git, curl, wget, xterm, ack, and more
|
||||||
|
|
||||||
|
๐ฌ Networking & Communication:
|
||||||
|
|
||||||
|
irssi for IRC and lynx for browsing from the terminal
|
||||||
|
|
||||||
|
๐ฆ Custom Tool Installed:
|
||||||
|
|
||||||
|
Latest version of AI vibe coding tool r is installed from the
|
||||||
|
retoor.molodetz.nl repository.
|
||||||
|
|
||||||
|
To get started, open a new terminal and type "r" to launch the tool.
|
||||||
|
|
||||||
|
Configure ~/.rcontext.txt to describe it's behavior. It can be a friend
|
||||||
|
or a whatever you like.
|
||||||
|
|
||||||
|
๐ Credentials:
|
||||||
|
|
||||||
|
Default root password is set to: root (please change it if needed)
|
||||||
|
|
||||||
|
๐ Custom Terminal Files:
|
||||||
|
|
||||||
|
Files from ./terminal/ have been copied to your home directory (/root/)
|
||||||
|
|
||||||
|
Enjoy hacking in your personalized command-line workspace!
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir("/root")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
sys.path.insert(0, "/root/bin")
|
||||||
|
sys.path.insert(0, "/root/bin/local/bin")
|
||||||
|
|
||||||
|
if not pathlib.Path(".welcome.txt").exists():
|
||||||
|
os.system("python3 -m venv --prompt '' .venv")
|
||||||
|
os.system("cp -r /opt/bootstrap/root/.* /root")
|
||||||
|
os.system("cp /opt/bootstrap/.welcome.txt /root/.welcome.txt")
|
||||||
|
pathlib.Path(".bashrc").write_text(pathlib.Path(".bashrc").read_text() + "\n" + "source .venv/bin/activate")
|
||||||
|
os.environ["SNEK"] = "1"
|
||||||
|
|
||||||
|
if pathlib.Path(".welcome.txt").exists():
|
||||||
|
with open(".welcome.txt") as f:
|
||||||
|
print(f.read())
|
||||||
|
|
||||||
|
os.system("bash")
|
||||||