forked from retoor/snek
temporary move
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import pathlib
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp_session import (
|
||||
get_session as session_get,
|
||||
session_middleware,
|
||||
setup as session_setup,
|
||||
)
|
||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
from app.app import Application as BaseApplication
|
||||
|
||||
from snek.docs.app import Application as DocsApplication
|
||||
from snek.mapper import get_mappers
|
||||
from snek.service import get_services
|
||||
from snek.system import http
|
||||
from snek.system.cache import Cache
|
||||
from snek.system.markdown import MarkdownExtension
|
||||
from snek.system.middleware import cors_middleware
|
||||
from snek.view.about import AboutHTMLView, AboutMDView
|
||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||
from snek.view.index import IndexView
|
||||
from snek.view.login import LoginView
|
||||
from snek.view.logout import LogoutView
|
||||
from snek.view.register import RegisterView
|
||||
from snek.view.rpc import RPCView
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.web import WebView
|
||||
|
||||
# base64.urlsafe_b64encode(
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def session_middleware(request, handler):
|
||||
setattr(request, "session", await session_get(request))
|
||||
response = await handler(request)
|
||||
return response
|
||||
|
||||
|
||||
class Application(BaseApplication):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
middlewares = [
|
||||
cors_middleware,
|
||||
web.normalize_path_middleware(merge_slashes=True),
|
||||
]
|
||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||
super().__init__(
|
||||
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
|
||||
)
|
||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||
self._middlewares.append(session_middleware)
|
||||
self.jinja2_env.add_extension(MarkdownExtension)
|
||||
self.setup_router()
|
||||
self.cache = Cache(self)
|
||||
self.services = get_services(app=self)
|
||||
self.mappers = get_mappers(app=self)
|
||||
|
||||
def setup_router(self):
|
||||
self.router.add_get("/", IndexView)
|
||||
self.router.add_static(
|
||||
"/",
|
||||
pathlib.Path(__file__).parent.joinpath("static"),
|
||||
name="static",
|
||||
show_index=True,
|
||||
)
|
||||
self.router.add_view("/about.html", AboutHTMLView)
|
||||
self.router.add_view("/about.md", AboutMDView)
|
||||
self.router.add_view("/logout.json", LogoutView)
|
||||
self.router.add_view("/logout.html", LogoutView)
|
||||
self.router.add_view("/docs.html", DocsHTMLView)
|
||||
self.router.add_view("/docs.md", DocsMDView)
|
||||
self.router.add_view("/status.json", StatusView)
|
||||
self.router.add_view("/web.html", WebView)
|
||||
self.router.add_view("/login.html", LoginView)
|
||||
self.router.add_view("/login.json", LoginView)
|
||||
self.router.add_view("/register.html", RegisterView)
|
||||
self.router.add_view("/register.json", RegisterView)
|
||||
self.router.add_get("/http-get", self.handle_http_get)
|
||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||
self.router.add_get("/rpc.ws", RPCView)
|
||||
self.add_subapp(
|
||||
"/docs",
|
||||
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
|
||||
)
|
||||
|
||||
async def handle_test(self, request):
|
||||
|
||||
return await self.render_template(
|
||||
"test.html", request, context={"name": "retoor"}
|
||||
)
|
||||
|
||||
async def handle_http_get(self, request: web.Request):
|
||||
url = request.query.get("url")
|
||||
content = await http.get(url)
|
||||
return web.Response(body=content)
|
||||
|
||||
async def handle_http_photo(self, request):
|
||||
url = request.query.get("url")
|
||||
path = await http.create_site_photo(url)
|
||||
return web.Response(
|
||||
body=path.read_bytes(), headers={"Content-Type": "image/png"}
|
||||
)
|
||||
|
||||
# @time_cache_async(60)
|
||||
async def render_template(self, template, request, context=None):
|
||||
return await super().render_template(template, request, context)
|
||||
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
web.run_app(app, port=8081, host="0.0.0.0")
|
||||
@@ -0,0 +1,51 @@
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
class AuthField(FormInputElement):
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
result = await super().errors
|
||||
if self.model.password.value and self.model.username.value:
|
||||
if not await self.app.services.user.validate_login(
|
||||
self.model.username.value, self.model.password.value
|
||||
):
|
||||
return ["Invalid username or password"]
|
||||
return result
|
||||
|
||||
|
||||
class LoginForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Login")
|
||||
|
||||
username = AuthField(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
place_holder="Username",
|
||||
type="text",
|
||||
)
|
||||
password = AuthField(
|
||||
name="password",
|
||||
required=True,
|
||||
regex=r"^[a-zA-Z0-9_.+-]{6,}",
|
||||
type="password",
|
||||
place_holder="Password",
|
||||
)
|
||||
|
||||
action = FormButtonElement(
|
||||
name="action", value="submit", text="Login", type="button"
|
||||
)
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
return all(
|
||||
[
|
||||
self["username"],
|
||||
self["password"],
|
||||
not await self.username.errors,
|
||||
not await self.password.errors,
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
class UsernameField(FormInputElement):
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
result = await super().errors
|
||||
if self.value and await self.app.services.user.count(username=self.value):
|
||||
result.append("Username is not available.")
|
||||
return result
|
||||
|
||||
|
||||
class RegisterForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Register")
|
||||
|
||||
username = UsernameField(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
place_holder="Username",
|
||||
type="text",
|
||||
)
|
||||
email = FormInputElement(
|
||||
name="email",
|
||||
required=False,
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
place_holder="Email address",
|
||||
type="email",
|
||||
)
|
||||
password = FormInputElement(
|
||||
name="password",
|
||||
required=True,
|
||||
regex=r"^[a-zA-Z0-9_.+-]{6,}",
|
||||
type="password",
|
||||
place_holder="Password",
|
||||
)
|
||||
|
||||
action = FormButtonElement(
|
||||
name="action", value="submit", text="Register", type="button"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from snek.app import app
|
||||
|
||||
application = app
|
||||
@@ -0,0 +1,25 @@
|
||||
import functools
|
||||
|
||||
from snek.mapper.channel import ChannelMapper
|
||||
from snek.mapper.channel_member import ChannelMemberMapper
|
||||
from snek.mapper.channel_message import ChannelMessageMapper
|
||||
from snek.mapper.notification import NotificationMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_mappers(app=None):
|
||||
return Object(
|
||||
**{
|
||||
"user": UserMapper(app=app),
|
||||
"channel_member": ChannelMemberMapper(app=app),
|
||||
"channel": ChannelMapper(app=app),
|
||||
"channel_message": ChannelMessageMapper(app=app),
|
||||
"notification": NotificationMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_mapper(name, app=None):
|
||||
return get_mappers(app=app)[name]
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel import ChannelModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMapper(BaseMapper):
|
||||
table_name = "channel"
|
||||
model_class = ChannelModel
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel_member import ChannelMemberModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMemberMapper(BaseMapper):
|
||||
table_name = "channel_member"
|
||||
model_class = ChannelMemberModel
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMessageMapper(BaseMapper):
|
||||
model_class = ChannelMessageModel
|
||||
table_name = "channel_message"
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.notification import NotificationModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class NotificationMapper(BaseMapper):
|
||||
table_name = "notification"
|
||||
model_class = NotificationModel
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class UserMapper(BaseMapper):
|
||||
table_name = "user"
|
||||
model_class = UserModel
|
||||
@@ -0,0 +1,25 @@
|
||||
import functools
|
||||
|
||||
from snek.model.channel import ChannelModel
|
||||
from snek.model.channel_member import ChannelMemberModel
|
||||
|
||||
# from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_models():
|
||||
return Object(
|
||||
**{
|
||||
"user": UserModel,
|
||||
"channel_member": ChannelMemberModel,
|
||||
"channel": ChannelModel,
|
||||
"channel_message": ChannelMessageModel,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_model(name):
|
||||
return get_models()[name]
|
||||
@@ -0,0 +1,11 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ChannelModel(BaseModel):
|
||||
label = ModelField(name="label", required=True, kind=str)
|
||||
description = ModelField(name="description", required=False, kind=str)
|
||||
tag = ModelField(name="tag", required=False, kind=str)
|
||||
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
|
||||
is_private = ModelField(name="is_private", required=True, kind=bool, value=False)
|
||||
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
|
||||
index = ModelField(name="index", required=True, kind=int, value=1000)
|
||||
@@ -0,0 +1,15 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ChannelMemberModel(BaseModel):
|
||||
label = ModelField(name="label", required=True, kind=str)
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
is_moderator = ModelField(
|
||||
name="is_moderator", required=True, kind=bool, value=False
|
||||
)
|
||||
is_read_only = ModelField(
|
||||
name="is_read_only", required=True, kind=bool, value=False
|
||||
)
|
||||
is_muted = ModelField(name="is_muted", required=True, kind=bool, value=False)
|
||||
is_banned = ModelField(name="is_banned", required=True, kind=bool, value=False)
|
||||
@@ -0,0 +1,8 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ChannelMessageModel(BaseModel):
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
message = ModelField(name="message", required=True, kind=str)
|
||||
html = ModelField(name="html", required=False, kind=str)
|
||||
@@ -0,0 +1,9 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class NotificationModel(BaseModel):
|
||||
object_uid = ModelField(name="object_uid", required=True)
|
||||
object_type = ModelField(name="object_type", required=True)
|
||||
message = ModelField(name="message", required=True)
|
||||
user_uid = ModelField(name="user_uid", required=True)
|
||||
read_at = ModelField(name="is_read", required=True)
|
||||
@@ -0,0 +1,31 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class UserModel(BaseModel):
|
||||
|
||||
username = ModelField(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
)
|
||||
nick = ModelField(
|
||||
name="nick",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
)
|
||||
color = ModelField(
|
||||
name ="color",
|
||||
required=True,
|
||||
regex=r"^#[0-9a-fA-F]{6}$",
|
||||
kind=str
|
||||
)
|
||||
email = ModelField(
|
||||
name="email",
|
||||
required=False,
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
)
|
||||
password = ModelField(name="password", required=True, regex=r"^[a-zA-Z0-9_.+-]{6,}")
|
||||
@@ -0,0 +1,31 @@
|
||||
import functools
|
||||
|
||||
from snek.service.channel import ChannelService
|
||||
from snek.service.channel_member import ChannelMemberService
|
||||
from snek.service.channel_message import ChannelMessageService
|
||||
from snek.service.chat import ChatService
|
||||
from snek.service.notification import NotificationService
|
||||
from snek.service.socket import SocketService
|
||||
from snek.service.user import UserService
|
||||
from snek.service.util import UtilService
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_services(app):
|
||||
return Object(
|
||||
**{
|
||||
"user": UserService(app=app),
|
||||
"channel_member": ChannelMemberService(app=app),
|
||||
"channel": ChannelService(app=app),
|
||||
"channel_message": ChannelMessageService(app=app),
|
||||
"chat": ChatService(app=app),
|
||||
"socket": SocketService(app=app),
|
||||
"notification": NotificationService(app=app),
|
||||
"util": UtilService(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_service(name, app=None):
|
||||
return get_services(app=app)[name]
|
||||
@@ -0,0 +1,48 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChannelService(BaseService):
|
||||
mapper_name = "channel"
|
||||
|
||||
async def create(
|
||||
self,
|
||||
label,
|
||||
created_by_uid,
|
||||
description=None,
|
||||
tag=None,
|
||||
is_private=False,
|
||||
is_listed=True,
|
||||
):
|
||||
if label[0] != "#" and is_listed:
|
||||
label = f"#{label}"
|
||||
count = await self.count(deleted_at=None)
|
||||
if not tag and not count:
|
||||
tag = "public"
|
||||
model = await self.new()
|
||||
model["label"] = label
|
||||
model["description"] = description
|
||||
model["tag"] = tag
|
||||
model["created_by_uid"] = created_by_uid
|
||||
model["is_private"] = is_private
|
||||
model["is_listed"] = is_listed
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel: {model.errors}.")
|
||||
|
||||
async def ensure_public_channel(self, created_by_uid):
|
||||
model = await self.get(is_listed=True, tag="public")
|
||||
is_moderator = False
|
||||
if not model:
|
||||
is_moderator = True
|
||||
model = await self.create(
|
||||
"public", created_by_uid=created_by_uid, is_listed=True, tag="public"
|
||||
)
|
||||
await self.app.services.channel_member.create(
|
||||
model["uid"],
|
||||
created_by_uid,
|
||||
is_moderator=is_moderator,
|
||||
is_read_only=False,
|
||||
is_muted=False,
|
||||
is_banned=False,
|
||||
)
|
||||
return model
|
||||
@@ -0,0 +1,34 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChannelMemberService(BaseService):
|
||||
|
||||
mapper_name = "channel_member"
|
||||
|
||||
async def create(
|
||||
self,
|
||||
channel_uid,
|
||||
user_uid,
|
||||
is_moderator=False,
|
||||
is_read_only=False,
|
||||
is_muted=False,
|
||||
is_banned=False,
|
||||
):
|
||||
model = await self.get(channel_uid=channel_uid, user_uid=user_uid)
|
||||
if model:
|
||||
if model.is_banned.value:
|
||||
return False
|
||||
return model
|
||||
model = await self.new()
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
model["label"] = channel["label"]
|
||||
model["channel_uid"] = channel_uid
|
||||
model["user_uid"] = user_uid
|
||||
model["is_moderator"] = is_moderator
|
||||
model["is_read_only"] = is_read_only
|
||||
model["is_muted"] = is_muted
|
||||
model["is_banned"] = is_banned
|
||||
print(model.record, flush=True)
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel member: {model.errors}.")
|
||||
@@ -0,0 +1,40 @@
|
||||
from snek.system.service import BaseService
|
||||
import jinja2
|
||||
|
||||
class ChannelMessageService(BaseService):
|
||||
mapper_name = "channel_message"
|
||||
|
||||
async def create(self, channel_uid, user_uid, message):
|
||||
model = await self.new()
|
||||
|
||||
model["channel_uid"] = channel_uid
|
||||
model["user_uid"] = user_uid
|
||||
model["message"] = message
|
||||
|
||||
context = {
|
||||
|
||||
}
|
||||
|
||||
|
||||
record = model.record
|
||||
context.update(record)
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
context.update(dict(
|
||||
user_uid=user['uid'],
|
||||
username=user['username'],
|
||||
user_nick=user['nick']
|
||||
))
|
||||
try:
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
except Exception as ex:
|
||||
print(ex,flush=True)
|
||||
print("RENDER",flush=True)
|
||||
print("RECORD",context,flush=True)
|
||||
|
||||
print("AFTER RENDER",flush=True)
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChatService(BaseService):
|
||||
|
||||
async def send(self,user_uid, channel_uid, message):
|
||||
channel_message = await self.services.channel_message.create(
|
||||
channel_uid,
|
||||
user_uid,
|
||||
message
|
||||
)
|
||||
channel_message_uid = channel_message["uid"]
|
||||
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
await self.services.notification.create_channel_message(channel_message_uid)
|
||||
sent_to_count = await self.services.socket.broadcast(channel_uid, dict(
|
||||
message=channel_message["message"],
|
||||
html=channel_message["html"],
|
||||
user_uid=user_uid,
|
||||
color=user['color'],
|
||||
channel_uid=channel_uid,
|
||||
created_at=channel_message["created_at"],
|
||||
updated_at=None,
|
||||
uid=channel_message['uid'],
|
||||
user_nick=user['nick']
|
||||
))
|
||||
return sent_to_count
|
||||
@@ -0,0 +1,37 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class NotificationService(BaseService):
|
||||
mapper_name = "notification"
|
||||
|
||||
async def create(self, object_uid, object_type, user_uid, message):
|
||||
model = await self.new()
|
||||
model["object_uid"] = object_uid
|
||||
model["object_type"] = object_type
|
||||
model["user_uid"] = user_uid
|
||||
model["message"] = message
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create notification: {model.errors}.")
|
||||
|
||||
async def create_channel_message(self, channel_message_uid):
|
||||
channel_message = await self.services.channel_message.get(
|
||||
uid=channel_message_uid
|
||||
)
|
||||
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||
async for channel_member in self.services.channel_member.find(
|
||||
channel_uid=channel_message["channel_uid"],
|
||||
is_banned=False,
|
||||
is_muted=False,
|
||||
deleted_at=None,
|
||||
):
|
||||
model = await self.new()
|
||||
model["object_uid"] = channel_message_uid
|
||||
model["object_type"] = "channel_message"
|
||||
model["user_uid"] = channel_member["user_uid"]
|
||||
model["message"] = (
|
||||
f"New message from {user['nick']} in {channel_member['label']}."
|
||||
)
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create notification: {model.errors}.")
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class SocketService(BaseService):
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.sockets = set()
|
||||
self.subscriptions = {}
|
||||
|
||||
async def add(self, ws):
|
||||
self.sockets.add(ws)
|
||||
|
||||
async def subscribe(self, ws, channel_uid):
|
||||
if not channel_uid in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
self.subscriptions[channel_uid].add(ws)
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
print("BROADCAT!",message)
|
||||
count = 0
|
||||
subscriptions = set(self.subscriptions.get(channel_uid,[]))
|
||||
for ws in subscriptions:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
print("Deleting socket.")
|
||||
self.subscriptions[channel_uid].remove(ws)
|
||||
continue
|
||||
count += 1
|
||||
return count
|
||||
async def delete(self, ws):
|
||||
try:
|
||||
self.sockets.remove(ws)
|
||||
except IndexError:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
from snek.system import security
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
mapper_name = "user"
|
||||
|
||||
async def validate_login(self, username, password):
|
||||
model = await self.get(username=username)
|
||||
if not model:
|
||||
return False
|
||||
if not await security.verify(password, model["password"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def save(self, user):
|
||||
if not user['color']:
|
||||
user['color'] = await self.services.util.random_light_hex_color()
|
||||
return await super().save(user)
|
||||
|
||||
async def register(self, email, username, password):
|
||||
if await self.exists(username=username):
|
||||
raise Exception("User already exists.")
|
||||
model = await self.new()
|
||||
model["nick"] = username
|
||||
model['color'] = await self.services.util.random_light_hex_color()
|
||||
model.email.value = email
|
||||
model.username.value = username
|
||||
model.password.value = await security.hash(password)
|
||||
if await self.save(model):
|
||||
if model:
|
||||
channel = await self.services.channel.ensure_public_channel(
|
||||
model["uid"]
|
||||
)
|
||||
if not channel:
|
||||
raise Exception("Failed to create public channel.")
|
||||
return model
|
||||
raise Exception(f"Failed to create user: {model.errors}.")
|
||||
@@ -0,0 +1,15 @@
|
||||
import random
|
||||
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class UtilService(BaseService):
|
||||
|
||||
async def random_light_hex_color(self):
|
||||
|
||||
r = random.randint(128, 255)
|
||||
g = random.randint(128, 255)
|
||||
b = random.randint(128, 255)
|
||||
|
||||
return "#{:02x}{:02x}{:02x}".format(r, g, b)
|
||||
@@ -0,0 +1,402 @@
|
||||
|
||||
|
||||
/*class Message {
|
||||
uid = null
|
||||
author = null
|
||||
avatar = null
|
||||
text = null
|
||||
time = null
|
||||
constructor(uid,avatar,author,text,time){
|
||||
this.uid = uid
|
||||
this.avatar = avatar
|
||||
this.author = author
|
||||
this.text = text
|
||||
this.time = time
|
||||
}
|
||||
|
||||
get links() {
|
||||
if(!this.text)
|
||||
return []
|
||||
let result = []
|
||||
for(let part in this.text.split(/[,; ]/)){
|
||||
if(part.startsWith("http") || part.startsWith("www.") || part.indexOf(".com") || part.indexOf(".net") || part.indexOf(".io") || part.indexOf(".nl")){
|
||||
result.push(part)
|
||||
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
get mentions() {
|
||||
if(!this.text)
|
||||
return []
|
||||
let result = []
|
||||
for(let part in this.text.split(/[,; ]/)){
|
||||
if(part.startsWith("@")){
|
||||
result.push(part)
|
||||
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
class Messages {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
class Room {
|
||||
name = null
|
||||
messages = []
|
||||
constructor(name) {
|
||||
this.name = name
|
||||
}
|
||||
setMessages(list) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
class InlineAppElement extends HTMLElement {
|
||||
|
||||
constructor() {
|
||||
// this.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Page {
|
||||
elements = []
|
||||
|
||||
}
|
||||
|
||||
class RESTClient {
|
||||
debug = false
|
||||
|
||||
async get(url, params) {
|
||||
params = params ? params : {}
|
||||
const encodedParams = new URLSearchParams(params);
|
||||
if (encodedParams)
|
||||
url += '?' + encodedParams
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json()
|
||||
if (this.debug) {
|
||||
console.debug({ url: url, params: params, result: result })
|
||||
}
|
||||
return result
|
||||
}
|
||||
async post(url, data) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json()
|
||||
if (this.debug) {
|
||||
console.debug({ url: url, params: params, result: result })
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
const rest = new RESTClient()
|
||||
|
||||
class EventHandler {
|
||||
|
||||
constructor() {
|
||||
this.subscribers = {}
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
if (!this.subscribers[type])
|
||||
this.subscribers[type] = []
|
||||
this.subscribers[type].push(handler)
|
||||
}
|
||||
emit(type, ...data) {
|
||||
if (this.subscribers[type])
|
||||
this.subscribers[type].forEach(handler => handler(...data))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Chat extends EventHandler {
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this._url = window.location.hostname == 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws'
|
||||
this._socket = null
|
||||
this._wait_connect = null
|
||||
this._promises = {}
|
||||
}
|
||||
connect() {
|
||||
if (this._wait_connect)
|
||||
return this._wait_connect
|
||||
|
||||
const me = this
|
||||
return new Promise(async (resolve, reject) => {
|
||||
me._wait_connect = resolve
|
||||
console.debug("Connecting..")
|
||||
|
||||
try {
|
||||
me._socket = new WebSocket(me._url)
|
||||
}catch(e){
|
||||
console.warning(e)
|
||||
setTimeout(()=>{
|
||||
me.ensureConnection()
|
||||
},1000)
|
||||
}
|
||||
|
||||
me._socket.onconnect = () => {
|
||||
me._connected()
|
||||
me._wait_socket(me)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
generateUniqueId() {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9); // Example: id-k5f9zq7
|
||||
}
|
||||
call(method, ...args) {
|
||||
const me = this
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const command = { method: method, args: args, message_id: me.generateUniqueId() }
|
||||
me._promises[command.message_id] = resolve
|
||||
await me._socket.send(JSON.stringify(command))
|
||||
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
_connected() {
|
||||
const me = this
|
||||
this._socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.message_id && me._promises[message.message_id]) {
|
||||
me._promises[message.message_id](message)
|
||||
delete me._promises[message.message_id]
|
||||
} else {
|
||||
me.emit("message", me, message)
|
||||
}
|
||||
//const room = this.rooms.find(room=>room.name == message.room)
|
||||
//if(!room){
|
||||
// this.rooms.push(new Room(message.room))
|
||||
}
|
||||
this._socket.onclose = (event) => {
|
||||
me._wait_socket = null
|
||||
me._socket = null
|
||||
me.emit('close', me)
|
||||
}
|
||||
}
|
||||
|
||||
async privmsg(room, text) {
|
||||
await rest.post("/api/privmsg", {
|
||||
room: room,
|
||||
text: text
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Socket extends EventHandler {
|
||||
ws = null
|
||||
isConnected = null
|
||||
isConnecting = null
|
||||
url = null
|
||||
connectPromises = []
|
||||
ensureTimer = null
|
||||
constructor() {
|
||||
super()
|
||||
this.url = window.location.hostname == 'localhost' ? 'ws://localhost:8081/rpc.ws' : 'wss://' + window.location.hostname + '/rpc.ws'
|
||||
this.ensureConnection()
|
||||
}
|
||||
_camelToSnake(str) {
|
||||
return str
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.toLowerCase();
|
||||
}
|
||||
get client() {
|
||||
const me = this
|
||||
const proxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, prop) {
|
||||
return (...args) => {
|
||||
let functionName = me._camelToSnake(prop)
|
||||
return me.call(functionName, ...args);
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
return proxy
|
||||
}
|
||||
ensureConnection() {
|
||||
if(this.ensureTimer)
|
||||
return this.connect()
|
||||
const me = this
|
||||
this.ensureTimer = setInterval(()=>{
|
||||
if (me.isConnecting)
|
||||
me.isConnecting = false
|
||||
me.connect()
|
||||
},5000)
|
||||
return this.connect()
|
||||
}
|
||||
generateUniqueId() {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
connect() {
|
||||
const me = this
|
||||
if (!this.isConnected && !this.isConnecting) {
|
||||
this.isConnecting = true
|
||||
} else if (this.isConnecting) {
|
||||
return new Promise((resolve, reject) => {
|
||||
me.connectPromises.push(resolve)
|
||||
})
|
||||
} else if (this.isConnected) {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(me)
|
||||
})
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
me.connectPromises.push(resolve)
|
||||
console.debug("Connecting..")
|
||||
|
||||
const ws = new WebSocket(this.url)
|
||||
|
||||
ws.onopen = (event) => {
|
||||
me.ws = ws
|
||||
me.isConnected = true
|
||||
me.isConnecting = false
|
||||
ws.onmessage = (event) => {
|
||||
me.onData(JSON.parse(event.data))
|
||||
}
|
||||
ws.onclose = (event) => {
|
||||
me.onClose()
|
||||
|
||||
}
|
||||
ws.onerror = (event)=>{
|
||||
me.onClose()
|
||||
}
|
||||
me.connectPromises.forEach(resolve => {
|
||||
resolve(me)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
onData(data) {
|
||||
if(data.success != undefined && !data.success){
|
||||
console.error(data)
|
||||
}
|
||||
|
||||
if (data.callId) {
|
||||
this.emit(data.callId, data.data)
|
||||
}
|
||||
if (data.channel_uid) {
|
||||
this.emit(data.channel_uid, data.data)
|
||||
this.emit("channel-message", data)
|
||||
}
|
||||
|
||||
}
|
||||
async sendJson(data) {
|
||||
return await this.connect().then((api) => {
|
||||
api.ws.send(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
async call(method, ...args) {
|
||||
const call = {
|
||||
callId: this.generateUniqueId(),
|
||||
method: method,
|
||||
args: args
|
||||
}
|
||||
|
||||
const me = this
|
||||
return new Promise(async (resolve, reject) => {
|
||||
me.addEventListener(call.callId, (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
await me.sendJson(call)
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
onClose() {
|
||||
console.info("Connection lost. Reconnecting.")
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
this.ensureConnection().then(() => {
|
||||
console.info("Reconnected.")
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class App extends EventHandler {
|
||||
rooms = []
|
||||
rest = rest
|
||||
ws = null
|
||||
rpc = null
|
||||
sounds = ["/audio/soundfx.d_beep3.mp3"]
|
||||
playSound(soundIndex) {
|
||||
if (!soundIndex)
|
||||
soundIndex = 0
|
||||
|
||||
const player = new Audio(this.sounds[soundIndex]);
|
||||
|
||||
player.play()
|
||||
.then(() => {
|
||||
console.debug("Gave sound notification")
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Notification failed:", error);
|
||||
});
|
||||
}
|
||||
constructor() {
|
||||
super()
|
||||
this.rooms.push(new Room("General"))
|
||||
this.ws = new Socket()
|
||||
this.rpc = this.ws.client
|
||||
const me = this
|
||||
this.ws.addEventListener("channel-message", (data) => {
|
||||
me.emit(data.channel_uid, data)
|
||||
})
|
||||
}
|
||||
async benchMark(times, message) {
|
||||
if (!times)
|
||||
times = 100
|
||||
if (!message)
|
||||
message = "Benchmark Message"
|
||||
let promises = []
|
||||
const me = this
|
||||
for (let i = 0; i < times; i++) {
|
||||
promises.push(this.rpc.getChannels().then(channels => {
|
||||
channels.forEach(channel => {
|
||||
me.rpc.sendMessage(channel.uid, `${message} ${i}`).then(data => {
|
||||
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
}
|
||||
//return await Promise.all(promises)
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const app = new App()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,238 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-width: 100%;
|
||||
}
|
||||
main {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #0f0f0f;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header .logo {
|
||||
color: #fff;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
margin-left: 15px;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
header nav a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: #121212;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
color: #f05a28;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px 20px;
|
||||
background-color: #0f0f0f;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
}
|
||||
message-list {
|
||||
flex: 1;;
|
||||
height: 200px;
|
||||
padding-bottom: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
height: 200px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.message-list-manager {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.chat-messages .message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0px;
|
||||
padding: 5px;
|
||||
/*background: #222;*/
|
||||
border-radius: 8px;
|
||||
/* box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);*/
|
||||
}
|
||||
|
||||
.chat-messages .message .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .author {
|
||||
font-weight: bold;
|
||||
color: #f05a28;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .text {
|
||||
margin-bottom: 5px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .time {
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 15px;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
background-color: #f05a28;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
.avatar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.message.switch-user {
|
||||
.avatar {
|
||||
opacity: 1;
|
||||
}
|
||||
.author {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.message:has(+ .message.switch-user), .message:last-child
|
||||
{
|
||||
.time {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
|
||||
class ChatInputElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.component = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
}
|
||||
connectedCallback() {
|
||||
const me = this
|
||||
const link = document.createElement("link")
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/base.css'
|
||||
this.component.appendChild(link)
|
||||
this.container = document.createElement('div')
|
||||
this.container.classList.add("chat-input")
|
||||
this.container.innerHTML = `
|
||||
<textarea placeholder="Type a message..." rows="2"></textarea>
|
||||
<button>Send</button>
|
||||
`;
|
||||
this.textBox = this.container.querySelector('textarea')
|
||||
this.textBox.addEventListener('input', (e) => {
|
||||
this.dispatchEvent(new CustomEvent("input", { detail: e.target.value, bubbles: true }))
|
||||
const message = e.target.value;
|
||||
const button = this.container.querySelector('button');
|
||||
button.disabled = !message;
|
||||
})
|
||||
this.textBox.addEventListener('change', (e) => {
|
||||
this.dispatchEvent(new CustomEvent("change", { detail: e.target.value, bubbles: true }))
|
||||
console.error(e.target.value)
|
||||
})
|
||||
this.textBox.addEventListener('keyup', (e) => {
|
||||
if (e.key == 'Enter' && !e.shiftKey) {
|
||||
this.dispatchEvent(new CustomEvent("submit", { detail: e.target.value, bubbles: true }))
|
||||
e.target.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
this.container.querySelector('button').addEventListener('click', (e) => {
|
||||
this.dispatchEvent(new CustomEvent("submit", { detail: me.textBox.value, bubbles: true }))
|
||||
setTimeout(()=>{
|
||||
me.textBox.value = ''
|
||||
me.textBox.focus()
|
||||
},200)
|
||||
})
|
||||
this.component.appendChild(this.container)
|
||||
}
|
||||
}
|
||||
customElements.define('chat-input', ChatInputElement);
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
|
||||
class ChatWindowElement extends HTMLElement {
|
||||
receivedHistory = false
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.component = document.createElement('section');
|
||||
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/base.css'
|
||||
this.component.appendChild(link)
|
||||
this.component.classList.add("chat-area")
|
||||
this.container = document.createElement("section")
|
||||
this.container.classList.add("chat-area")
|
||||
this.container.classList.add("chat-window")
|
||||
|
||||
const chatHeader = document.createElement("div")
|
||||
chatHeader.classList.add("chat-header")
|
||||
|
||||
|
||||
|
||||
const chatTitle = document.createElement('h2')
|
||||
chatTitle.classList.add("chat-title")
|
||||
chatTitle.innerText = "Loading..."
|
||||
chatHeader.appendChild(chatTitle)
|
||||
this.container.appendChild(chatHeader)
|
||||
const channels = await app.rpc.getChannels()
|
||||
const channel = channels[0]
|
||||
chatTitle.innerText = channel.name
|
||||
const channelElement = document.createElement('message-list')
|
||||
channelElement.setAttribute("channel", channel.uid)
|
||||
//channelElement.classList.add("chat-messages")
|
||||
this.container.appendChild(channelElement)
|
||||
|
||||
const chatInput = document.createElement('chat-input')
|
||||
|
||||
chatInput.addEventListener("submit",(e)=>{
|
||||
app.rpc.sendMessage(channel.uid,e.detail)
|
||||
})
|
||||
this.container.appendChild(chatInput)
|
||||
|
||||
this.component.appendChild(this.container)
|
||||
const messages = await app.rpc.getMessages(channel.uid)
|
||||
messages.forEach(message=>{
|
||||
if(!message['user_nick'])
|
||||
return
|
||||
channelElement.addMessage(message)
|
||||
})
|
||||
const me = this
|
||||
channelElement.addEventListener("message",(message)=>{
|
||||
app.playSound(0)
|
||||
message.detail.element.scrollIntoView()
|
||||
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
customElements.define('chat-window', ChatWindowElement);
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
|
||||
class FancyButton extends HTMLElement {
|
||||
url = null
|
||||
type="button"
|
||||
value = null
|
||||
constructor(){
|
||||
super()
|
||||
this.attachShadow({mode:'open'})
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
||||
this.container = document.createElement('span')
|
||||
let size = this.getAttribute('size')
|
||||
console.info({GG:size})
|
||||
if(size == 'auto'){
|
||||
size = '1%'
|
||||
}else{
|
||||
size = '33%'
|
||||
}
|
||||
this.styleElement = document.createElement("style")
|
||||
this.styleElement.innerHTML = `
|
||||
:root {
|
||||
width:100%;
|
||||
--width: 100%;
|
||||
}
|
||||
button {
|
||||
width: var(--width);
|
||||
min-width: ${size};
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
border: 1px solid #f05a28;
|
||||
}
|
||||
button:hover {
|
||||
color: #EFEFEF;
|
||||
background-color: #e04924;
|
||||
border: 1px solid #efefef;
|
||||
}
|
||||
`
|
||||
this.container.appendChild(this.styleElement)
|
||||
this.buttonElement = document.createElement('button')
|
||||
this.container.appendChild(this.buttonElement)
|
||||
this.shadowRoot.appendChild(this.container)
|
||||
|
||||
this.url = this.getAttribute('url');
|
||||
this.value = this.getAttribute('value')
|
||||
const me = this
|
||||
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")))
|
||||
this.buttonElement.addEventListener("click",()=>{
|
||||
if(me.url == "/back" || me.url == "/back/"){
|
||||
window.history.back()
|
||||
}else if(me.url){
|
||||
window.location = me.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("fancy-button",FancyButton)
|
||||
@@ -0,0 +1,100 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
generic-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: #000000;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 400px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
input {
|
||||
|
||||
border: 10px solid #000000;
|
||||
}
|
||||
.generic-form-container generic-field {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.generic-form-container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.generic-form-container button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
.generic-form-container a {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.generic-form-container a:hover {
|
||||
color: #e04924;
|
||||
}
|
||||
|
||||
|
||||
.error {
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.generic-form-container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
|
||||
class GenericField extends HTMLElement {
|
||||
form = null
|
||||
field = null
|
||||
inputElement = null
|
||||
footerElement = null
|
||||
action = null
|
||||
container = null
|
||||
styleElement = null
|
||||
name = null
|
||||
get value() {
|
||||
return this.inputElement.value
|
||||
}
|
||||
get type() {
|
||||
|
||||
return this.field.tag
|
||||
}
|
||||
set value(val) {
|
||||
val = val == null ? '' : val
|
||||
this.inputElement.value = val
|
||||
this.inputElement.setAttribute("value", val)
|
||||
}
|
||||
setInvalid(){
|
||||
this.inputElement.classList.add("error")
|
||||
this.inputElement.classList.remove("valid")
|
||||
}
|
||||
setErrors(errors){
|
||||
if(errors.length)
|
||||
this.inputElement.setAttribute("title", errors[0])
|
||||
else
|
||||
this.inputElement.setAttribute("title","")
|
||||
}
|
||||
setValid(){
|
||||
this.inputElement.classList.remove("error")
|
||||
this.inputElement.classList.add("valid")
|
||||
}
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({mode:'open'})
|
||||
this.container = document.createElement('div')
|
||||
this.styleElement = document.createElement('style')
|
||||
this.styleElement.innerHTML = `
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 50%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #e04924;
|
||||
}
|
||||
.valid {
|
||||
border: 1px solid green;
|
||||
color:green;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.error {
|
||||
border: 3px solid red;
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
input {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
`
|
||||
this.container.appendChild(this.styleElement)
|
||||
|
||||
this.shadowRoot.appendChild(this.container)
|
||||
}
|
||||
connectedCallback(){
|
||||
|
||||
this.updateAttributes()
|
||||
|
||||
}
|
||||
setAttribute(name,value){
|
||||
this[name] = value
|
||||
}
|
||||
updateAttributes(){
|
||||
if(this.inputElement == null && this.field){
|
||||
this.inputElement = document.createElement(this.field.tag)
|
||||
if(this.field.tag == 'button'){
|
||||
if(this.field.value == "submit"){
|
||||
|
||||
|
||||
}
|
||||
this.action = this.field.value
|
||||
}
|
||||
this.inputElement.name = this.field.name
|
||||
this.name = this.inputElement.name
|
||||
const me = this
|
||||
this.inputElement.addEventListener("keyup",(e)=>{
|
||||
if(e.key == 'Enter'){
|
||||
me.dispatchEvent(new Event("submit"))
|
||||
}else if(me.field.value != e.target.value)
|
||||
{
|
||||
const event = new CustomEvent("change", {detail:me,bubbles:true})
|
||||
me.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
this.inputElement.addEventListener("click",(e)=>{
|
||||
const event = new CustomEvent("click",{detail:me,bubbles:true})
|
||||
me.dispatchEvent(event)
|
||||
})
|
||||
this.container.appendChild(this.inputElement)
|
||||
|
||||
}
|
||||
if(!this.field){
|
||||
return
|
||||
}
|
||||
this.inputElement.setAttribute("type",this.field.type == null ? 'input' : this.field.type)
|
||||
this.inputElement.setAttribute("name",this.field.name == null ? '' : this.field.name)
|
||||
|
||||
if(this.field.text != null){
|
||||
this.inputElement.innerText = this.field.text
|
||||
}
|
||||
if(this.field.html != null){
|
||||
this.inputElement.innerHTML = this.field.html
|
||||
}
|
||||
if(this.field.class_name){
|
||||
this.inputElement.classList.add(this.field.class_name)
|
||||
}
|
||||
this.inputElement.setAttribute("tabindex", this.field.index)
|
||||
this.inputElement.classList.add(this.field.name)
|
||||
this.value = this.field.value
|
||||
let place_holder = null
|
||||
if(this.field.place_holder)
|
||||
place_holder = this.field.place_holder
|
||||
if(this.field.required && place_holder){
|
||||
place_holder = place_holder
|
||||
}
|
||||
if(place_holder)
|
||||
this.field.place_holder = "* " + place_holder
|
||||
this.inputElement.setAttribute("placeholder",place_holder)
|
||||
if(this.field.required)
|
||||
this.inputElement.setAttribute("required","required")
|
||||
else
|
||||
this.inputElement.removeAttribute("required")
|
||||
if(!this.footerElement){
|
||||
this.footerElement = document.createElement('div')
|
||||
this.footerElement.style.clear = 'both'
|
||||
this.container.appendChild(this.footerElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('generic-field', GenericField);
|
||||
|
||||
class GenericForm extends HTMLElement {
|
||||
fields = {}
|
||||
form = {}
|
||||
constructor() {
|
||||
|
||||
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.styleElement = document.createElement("style")
|
||||
this.styleElement.innerHTML = `
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
width:90%
|
||||
|
||||
}
|
||||
|
||||
div {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 400px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
width:100%;
|
||||
height:100%;
|
||||
form {
|
||||
height:100%;
|
||||
width: 100%;
|
||||
width: 80%;
|
||||
}
|
||||
}`
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.appendChild(this.styleElement)
|
||||
this.container.classList.add("generic-form-container")
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url');
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadForm(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No URL provided!";
|
||||
}
|
||||
}
|
||||
|
||||
async loadForm(url) {
|
||||
const me = this
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
me.form = await response.json();
|
||||
|
||||
let fields = Object.values(me.form.fields)
|
||||
|
||||
fields = fields.sort((a,b)=>{
|
||||
console.info(a.index,b.index)
|
||||
return a.index - b.index
|
||||
})
|
||||
fields.forEach(field=>{
|
||||
const fieldElement = document.createElement('generic-field')
|
||||
me.fields[field.name] = fieldElement
|
||||
fieldElement.setAttribute("form", me)
|
||||
fieldElement.setAttribute("field", field)
|
||||
me.container.appendChild(fieldElement)
|
||||
fieldElement.updateAttributes()
|
||||
fieldElement.addEventListener("change",(e)=>{
|
||||
me.form.fields[e.detail.name].value = e.detail.value
|
||||
})
|
||||
fieldElement.addEventListener("click",async (e)=>{
|
||||
if(e.detail.type == "button"){
|
||||
if(e.detail.value == "submit")
|
||||
{
|
||||
const isValid = await me.validate()
|
||||
if(isValid){
|
||||
const saveResult = await me.submit()
|
||||
if(saveResult.redirect_url){
|
||||
window.location.pathname = saveResult.redirect_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
async validate(){
|
||||
const url = this.getAttribute("url")
|
||||
const me = this
|
||||
let response = await fetch(url,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({"action":"validate", "form":me.form})
|
||||
});
|
||||
|
||||
|
||||
const form = await response.json()
|
||||
Object.values(form.fields).forEach(field=>{
|
||||
if(!me.form.fields[field.name])
|
||||
return
|
||||
me.form.fields[field.name].is_valid = field.is_valid
|
||||
if(!field.is_valid){
|
||||
me.fields[field.name].setInvalid()
|
||||
me.fields[field.name].setErrors(field.errors)
|
||||
}else{
|
||||
me.fields[field.name].setValid()
|
||||
}
|
||||
me.fields[field.name].setAttribute("field",field)
|
||||
me.fields[field.name].updateAttributes()
|
||||
})
|
||||
Object.values(form.fields).forEach(field=>{
|
||||
me.fields[field.name].setErrors(field.errors)
|
||||
})
|
||||
return form['is_valid']
|
||||
}
|
||||
async submit(){
|
||||
const me = this
|
||||
const url = me.getAttribute("url")
|
||||
const response = await fetch(url,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({"action":"submit", "form":me.form})
|
||||
});
|
||||
return await response.json()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
customElements.define('generic-form', GenericForm);
|
||||
@@ -0,0 +1,6 @@
|
||||
.html-frame {
|
||||
width: 100px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
border: 1px solid black;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
class HTMLFrame extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.container = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame")
|
||||
let url = this.getAttribute('url');
|
||||
if(!url.startsWith("https")){
|
||||
url = "https://" + url
|
||||
}
|
||||
if (url) {
|
||||
let fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
}
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
if(url.endsWith(".md")){
|
||||
const parent = this
|
||||
const markdownElement = document.createElement('div')
|
||||
markdownElement.innerHTML = html
|
||||
this.outerHTML = html
|
||||
}else{
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('html-frame', HTMLFrame);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Snek",
|
||||
"description": "Danger noodle",
|
||||
"display": "standalone",
|
||||
"start_url": "/web.html",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/image/snek1.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
|
||||
class HTMLFrame extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.container = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame")
|
||||
const url = this.getAttribute('url');
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
}
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
this.container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('markdown-frame', HTMLFrame);
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
class MessageListManagerElement extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({mode:'open'})
|
||||
this.container = document.createElement("div")
|
||||
this.shadowRoot.appendChild(this.container)
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
let channels = await app.rpc.getChannels()
|
||||
const me = this
|
||||
channels.forEach(channel=>{
|
||||
const messageList = document.createElement("message-list")
|
||||
messageList.setAttribute("channel",channel.uid)
|
||||
me.container.appendChild(messageList)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
customElements.define("message-list-manager",MessageListManagerElement)
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
|
||||
class MessageListElement extends HTMLElement {
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["messages"];
|
||||
}
|
||||
messages = []
|
||||
room = null
|
||||
url = null
|
||||
container = null
|
||||
messageEventSchedule = null
|
||||
observer = null
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.component = document.createElement('div')
|
||||
this.shadowRoot.appendChild(this.component )
|
||||
}
|
||||
linkifyText(text) {
|
||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
||||
|
||||
return text.replace(urlRegex, (url) => {
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
}
|
||||
createElement(message){
|
||||
const element = document.createElement("div")
|
||||
element.dataset.uid = message.uid
|
||||
element.dataset.color = message.color
|
||||
element.dataset.channel_uid = message.channel_uid
|
||||
element.dataset.user_nick = message.user_nick
|
||||
element.dataset.created_at = message.created_at
|
||||
element.dataset.user_uid = message.user_uid
|
||||
element.dataset.message = message.message
|
||||
|
||||
element.classList.add("message")
|
||||
if(!this.messages.length){
|
||||
element.classList.add("switch-user")
|
||||
}else if (this.messages[this.messages.length-1].user_uid != message.user_uid){
|
||||
element.classList.add("switch-user")
|
||||
}
|
||||
const avatar = document.createElement("div")
|
||||
avatar.classList.add("avatar")
|
||||
avatar.style.backgroundColor = message.color
|
||||
avatar.style.color= "black"
|
||||
avatar.innerText = message.user_nick[0]
|
||||
const messageContent = document.createElement("div")
|
||||
messageContent.classList.add("message-content")
|
||||
const author = document.createElement("div")
|
||||
author.classList.add("author")
|
||||
author.style.color = message.color
|
||||
author.textContent = message.user_nick
|
||||
const text = document.createElement("div")
|
||||
text.classList.add("text")
|
||||
if(message.html)
|
||||
text.innerHTML = this.linkifyText(message.html)
|
||||
const time = document.createElement("div")
|
||||
time.classList.add("time")
|
||||
time.textContent = message.created_at
|
||||
messageContent.appendChild(author)
|
||||
messageContent.appendChild(text)
|
||||
messageContent.appendChild(time)
|
||||
element.appendChild(avatar)
|
||||
element.appendChild(messageContent)
|
||||
|
||||
|
||||
|
||||
|
||||
message.element = element
|
||||
|
||||
return element
|
||||
}
|
||||
addMessage(message){
|
||||
|
||||
const obj = new models.Message(
|
||||
message.uid,
|
||||
message.channel_uid,
|
||||
message.user_uid,
|
||||
message.user_nick,
|
||||
message.color,
|
||||
message.message,
|
||||
message.html,
|
||||
message.created_at,
|
||||
message.updated_at
|
||||
)
|
||||
const element = this.createElement(obj)
|
||||
this.messages.push(obj)
|
||||
this.container.appendChild(element)
|
||||
const me = this
|
||||
|
||||
this.messageEventSchedule.delay(() => {
|
||||
me.dispatchEvent(new CustomEvent("message", {detail:obj,bubbles:true}))
|
||||
|
||||
})
|
||||
|
||||
|
||||
return obj
|
||||
}
|
||||
scrollBottom(){
|
||||
this.container.scrollTop = this.container.scrollHeight;
|
||||
}
|
||||
connectedCallback() {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/base.css'
|
||||
this.component.appendChild(link)
|
||||
this.component.classList.add("chat-messages")
|
||||
this.container = document.createElement('div')
|
||||
//this.container.classList.add("chat-messages")
|
||||
this.component.appendChild(this.container)
|
||||
this.messageEventSchedule = new Schedule(500)
|
||||
this.messages = []
|
||||
this.channel_uid = this.getAttribute("channel")
|
||||
const me = this
|
||||
app.addEventListener(this.channel_uid, (data) => {
|
||||
me.addMessage(data)
|
||||
})
|
||||
this.dispatchEvent(new CustomEvent("rendered", {detail:this,bubbles:true}))
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('message-list', MessageListElement);
|
||||
@@ -0,0 +1,26 @@
|
||||
class MessageModel {
|
||||
message = null
|
||||
html = null
|
||||
user_uid = null
|
||||
channel_uid = null
|
||||
created_at = null
|
||||
updated_at = null
|
||||
element = null
|
||||
color = null
|
||||
constructor(uid, channel_uid,user_uid,user_nick, color,message,html,created_at, updated_at){
|
||||
this.uid = uid
|
||||
this.message = message
|
||||
this.html = html
|
||||
this.user_uid = user_uid
|
||||
this.user_nick = user_nick
|
||||
this.color = color
|
||||
this.channel_uid = channel_uid
|
||||
this.created_at = created_at
|
||||
this.updated_at = updated_at
|
||||
}
|
||||
}
|
||||
|
||||
const models = {
|
||||
Message: MessageModel
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.registration-container {
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 400px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
left: calc(50%-200);
|
||||
}
|
||||
|
||||
.registration-container h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.registration-container input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.registration-container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.registration-container button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
.registration-container a {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.registration-container a:hover {
|
||||
color: #e04924;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.registration-container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
|
||||
class Schedule {
|
||||
|
||||
constructor(msDelay) {
|
||||
if(!msDelay){
|
||||
msDelay = 100
|
||||
}
|
||||
this.msDelay = msDelay
|
||||
this._once = false
|
||||
this.timeOutCount = 0;
|
||||
this.timeOut = null
|
||||
this.interval = null
|
||||
}
|
||||
cancelRepeat() {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
cancelDelay() {
|
||||
clearTimeout(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
repeat(func){
|
||||
if(this.interval){
|
||||
return false
|
||||
}
|
||||
this.interval = setInterval(()=>{
|
||||
func()
|
||||
}, this.msDelay)
|
||||
}
|
||||
delay(func) {
|
||||
this.timeOutCount++
|
||||
if(this.timeOut){
|
||||
this.cancelDelay()
|
||||
}
|
||||
const me = this
|
||||
this.timeOut = setTimeout(()=>{
|
||||
clearTimeout(me.timeOut)
|
||||
me.timeOut = null
|
||||
func(me.timeOutCount)
|
||||
me.cancelDelay()
|
||||
me.timeOutCount = 0
|
||||
}, this.msDelay)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
* {
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 800px;
|
||||
margin: 30px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.center {
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
}
|
||||
.dialog {
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
|
||||
}
|
||||
div {
|
||||
text-align: left;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import functools
|
||||
import json
|
||||
|
||||
from snek.system import security
|
||||
|
||||
cache = functools.cache
|
||||
|
||||
CACHE_MAX_ITEMS_DEFAULT = 5000
|
||||
|
||||
|
||||
class Cache:
|
||||
def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):
|
||||
self.app = app
|
||||
self.cache = {}
|
||||
self.max_items = max_items
|
||||
self.lru = []
|
||||
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
||||
|
||||
async def get(self, args):
|
||||
try:
|
||||
self.lru.pop(self.lru.index(args))
|
||||
except:
|
||||
print("Cache miss!", args, flush=True)
|
||||
return None
|
||||
self.lru.insert(0, args)
|
||||
while len(self.lru) > self.max_items:
|
||||
self.cache.pop(self.lru[-1])
|
||||
self.lru.pop()
|
||||
print("Cache hit!", args, flush=True)
|
||||
return self.cache[args]
|
||||
|
||||
def json_default(self, value):
|
||||
# if hasattr(value, "to_json"):
|
||||
# return value.to_json()
|
||||
try:
|
||||
return json.dumps(value.__dict__, default=str)
|
||||
except:
|
||||
return str(value)
|
||||
|
||||
async def create_cache_key(self, args, kwargs):
|
||||
return await security.hash(
|
||||
json.dumps(
|
||||
{"args": args, "kwargs": kwargs},
|
||||
sort_keys=True,
|
||||
default=self.json_default,
|
||||
)
|
||||
)
|
||||
|
||||
async def set(self, args, result):
|
||||
is_new = args not in self.cache
|
||||
self.cache[args] = result
|
||||
try:
|
||||
self.lru.pop(self.lru.index(args))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
self.lru.insert(0, args)
|
||||
|
||||
while len(self.lru) > self.max_items:
|
||||
self.cache.pop(self.lru[-1])
|
||||
self.lru.pop()
|
||||
|
||||
if is_new:
|
||||
self.version += 1
|
||||
print("Cache store! New version:", self.version, flush=True)
|
||||
|
||||
async def delete(self, args):
|
||||
if args in self.cache:
|
||||
try:
|
||||
self.lru.pop(self.lru.index(args))
|
||||
except IndexError:
|
||||
pass
|
||||
del self.cache[args]
|
||||
|
||||
def async_cache(self, func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
cache_key = await self.create_cache_key(args, kwargs)
|
||||
cached = await self.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
result = await func(*args, **kwargs)
|
||||
await self.set(cache_key, result)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
def async_delete_cache(self, func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
cache_key = await self.create_cache_key(args, kwargs)
|
||||
if cache_key in self.cache:
|
||||
try:
|
||||
self.lru.pop(self.lru.index(cache_key))
|
||||
except IndexError:
|
||||
pass
|
||||
del self.cache[cache_key]
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_cache(func):
|
||||
cache = {}
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args):
|
||||
if args in cache:
|
||||
return cache[args]
|
||||
result = await func(*args)
|
||||
cache[args] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,120 @@
|
||||
# Written by retoor@molodetz.nl
|
||||
|
||||
# This code defines a framework for handling HTML elements as Python objects, including specific classes for HTML, form input, and form button elements. It offers methods to convert these elements to JSON, manipulate them, and validate form data.
|
||||
|
||||
# This code uses the `snek.system.model` library for managing model fields.
|
||||
|
||||
# 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:
|
||||
#
|
||||
# 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.
|
||||
|
||||
from snek.system import model
|
||||
|
||||
|
||||
class HTMLElement(model.ModelField):
|
||||
def __init__(
|
||||
self,
|
||||
id=None,
|
||||
tag="div",
|
||||
name=None,
|
||||
html=None,
|
||||
class_name=None,
|
||||
text=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.tag = tag
|
||||
self.text = text
|
||||
self.id = id
|
||||
self.class_name = class_name or name
|
||||
self.html = html
|
||||
super().__init__(name=name, *args, **kwargs)
|
||||
|
||||
async def to_json(self):
|
||||
result = await super().to_json()
|
||||
result["text"] = self.text
|
||||
result["id"] = self.id
|
||||
result["html"] = self.html
|
||||
result["class_name"] = self.class_name
|
||||
result["tag"] = self.tag
|
||||
return result
|
||||
|
||||
|
||||
class FormElement(HTMLElement):
|
||||
pass
|
||||
|
||||
|
||||
class FormInputElement(FormElement):
|
||||
def __init__(self, type="text", place_holder=None, *args, **kwargs):
|
||||
super().__init__(tag="input", *args, **kwargs)
|
||||
self.place_holder = place_holder
|
||||
self.type = type
|
||||
|
||||
async def to_json(self):
|
||||
data = await super().to_json()
|
||||
data["place_holder"] = self.place_holder
|
||||
data["type"] = self.type
|
||||
return data
|
||||
|
||||
|
||||
class FormButtonElement(FormElement):
|
||||
def __init__(self, tag="button", *args, **kwargs):
|
||||
super().__init__(tag=tag, *args, **kwargs)
|
||||
|
||||
|
||||
class Form(model.BaseModel):
|
||||
@property
|
||||
def html_elements(self):
|
||||
return [element for element in self.fields if isinstance(element, HTMLElement)]
|
||||
|
||||
def set_user_data(self, data):
|
||||
return super().set_user_data(data.get("fields"))
|
||||
|
||||
async def to_json(self, encode=False):
|
||||
elements = await super().to_json()
|
||||
html_elements = {}
|
||||
for element in elements.keys():
|
||||
if element == "is_valid":
|
||||
# is_valid is async get property so we can't do getattr on it
|
||||
continue
|
||||
field = getattr(self, element)
|
||||
if isinstance(field, HTMLElement):
|
||||
try:
|
||||
html_elements[element] = elements[element]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
is_valid = all(field["is_valid"] for field in html_elements.values())
|
||||
return {
|
||||
"fields": html_elements,
|
||||
"is_valid": is_valid,
|
||||
"errors": await self.errors,
|
||||
}
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
result = []
|
||||
for field in self.html_elements:
|
||||
result += await field.errors
|
||||
return result
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
# This is not good, but timebox to resolve issue exceeded.
|
||||
return False
|
||||
@@ -0,0 +1,111 @@
|
||||
# Written by retoor@molodetz.nl
|
||||
|
||||
# This script enables downloading, processing, and caching web content, including taking website screenshots and repairing links in HTML content.
|
||||
|
||||
# Imports used: aiohttp, aiohttp.web for creating web servers and handling async requests; app.cache for caching utilities; BeautifulSoup from bs4 for HTML parsing; imgkit for creating screenshots.
|
||||
|
||||
# The MIT License (MIT)
|
||||
# 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.
|
||||
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import uuid
|
||||
import zlib
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
import imgkit
|
||||
from app.cache import time_cache_async
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
async def crc32(data):
|
||||
try:
|
||||
data = data.encode()
|
||||
except:
|
||||
pass
|
||||
return "crc32" + str(zlib.crc32(data))
|
||||
|
||||
|
||||
async def get_file(name, suffix=".cache"):
|
||||
name = await crc32(name)
|
||||
path = pathlib.Path(".").joinpath("cache")
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path.joinpath(name + suffix)
|
||||
|
||||
|
||||
async def public_touch(name=None):
|
||||
path = pathlib.Path(".").joinpath(str(uuid.uuid4()) + name)
|
||||
path.open("wb").close()
|
||||
return path
|
||||
|
||||
|
||||
async def create_site_photo(url):
|
||||
loop = asyncio.get_event_loop()
|
||||
if not url.startswith("https"):
|
||||
url = "https://" + url
|
||||
output_path = await get_file("site-screenshot-" + url, ".png")
|
||||
|
||||
if output_path.exists():
|
||||
return output_path
|
||||
output_path.touch()
|
||||
|
||||
def make_photo():
|
||||
imgkit.from_url(url, output_path.absolute())
|
||||
return output_path
|
||||
|
||||
return await loop.run_in_executor(None, make_photo)
|
||||
|
||||
|
||||
async def repair_links(base_url, html_content):
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
for tag in soup.find_all(["a", "img", "link"]):
|
||||
if tag.has_attr("href") and not tag["href"].startswith("http"):
|
||||
tag["href"] = urljoin(base_url, tag["href"])
|
||||
if tag.has_attr("src") and not tag["src"].startswith("http"):
|
||||
tag["src"] = urljoin(base_url, tag["src"])
|
||||
return soup.prettify()
|
||||
|
||||
|
||||
async def is_html_content(content: bytes):
|
||||
try:
|
||||
content = content.decode(errors="ignore")
|
||||
except:
|
||||
pass
|
||||
marks = ["<html", "<img", "<p", "<span", "<div"]
|
||||
try:
|
||||
content = content.lower()
|
||||
for mark in marks:
|
||||
if mark in content:
|
||||
return True
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
return False
|
||||
|
||||
|
||||
@time_cache_async(120)
|
||||
async def get(url):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await session.get(url)
|
||||
content = await response.text()
|
||||
if await is_html_content(content):
|
||||
content = (await repair_links(url, content)).encode()
|
||||
return content
|
||||
@@ -0,0 +1,70 @@
|
||||
DEFAULT_LIMIT = 30
|
||||
import typing
|
||||
|
||||
from snek.system.model import BaseModel
|
||||
|
||||
|
||||
class BaseMapper:
|
||||
|
||||
model_class: BaseModel = None
|
||||
default_limit: int = DEFAULT_LIMIT
|
||||
table_name: str = None
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
self.default_limit = self.__class__.default_limit
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.app.db
|
||||
|
||||
async def new(self):
|
||||
return self.model_class(mapper=self)
|
||||
|
||||
@property
|
||||
def table(self):
|
||||
return self.db[self.table_name]
|
||||
|
||||
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
||||
if uid:
|
||||
kwargs["uid"] = uid
|
||||
record = self.table.find_one(**kwargs)
|
||||
if not record:
|
||||
return None
|
||||
record = dict(record)
|
||||
model = await self.new()
|
||||
for key, value in record.items():
|
||||
model[key] = value
|
||||
return model
|
||||
return await self.model_class.from_record(mapper=self, record=record)
|
||||
|
||||
async def exists(self, **kwargs):
|
||||
return self.table.exists(**kwargs)
|
||||
|
||||
async def count(self, **kwargs) -> int:
|
||||
return self.table.count(**kwargs)
|
||||
|
||||
async def save(self, model: BaseModel) -> bool:
|
||||
if not model.record.get("uid"):
|
||||
raise Exception(f"Attempt to save without uid: {model.record}.")
|
||||
model.updated_at.update()
|
||||
return self.table.upsert(model.record, ["uid"])
|
||||
|
||||
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
||||
if not kwargs.get("_limit"):
|
||||
kwargs["_limit"] = self.default_limit
|
||||
for record in self.table.find(**kwargs):
|
||||
model = await self.new()
|
||||
for key, value in record.items():
|
||||
model[key] = value
|
||||
yield model
|
||||
|
||||
async def query(self, sql, *args):
|
||||
for record in self.db.query(sql, *args):
|
||||
yield dict(record)
|
||||
|
||||
async def delete(self, kwargs=None) -> int:
|
||||
if not kwargs or not isinstance(kwargs, dict):
|
||||
raise Exception("Can't execute delete with no filter.")
|
||||
return self.table.delete(**kwargs)
|
||||
@@ -0,0 +1,81 @@
|
||||
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.cache import time_cache_async
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from pygments import highlight
|
||||
from pygments.formatters import html
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
|
||||
|
||||
class MarkdownRenderer(HTMLRenderer):
|
||||
|
||||
_allow_harmful_protocols = True
|
||||
|
||||
def __init__(self, app, template):
|
||||
self.template = template
|
||||
|
||||
self.app = app
|
||||
self.env = self.app.jinja2_env
|
||||
formatter = html.HtmlFormatter()
|
||||
self.env.globals["highlight_styles"] = formatter.get_style_defs()
|
||||
|
||||
def _escape(self, str):
|
||||
return str ##escape(str)
|
||||
|
||||
def block_code(self, code, lang=None, info=None):
|
||||
if not lang:
|
||||
lang = info
|
||||
if not lang:
|
||||
return f"<div>{code}</div>"
|
||||
# return '\n<pre><code>%s</code></pre>\n' % escape(code)
|
||||
lexer = get_lexer_by_name(lang, stripall=True)
|
||||
formatter = html.HtmlFormatter(lineseparator="<br>")
|
||||
return highlight(code, lexer, formatter)
|
||||
|
||||
def render(self):
|
||||
markdown_string = self.app.template_path.joinpath(self.template).read_text()
|
||||
renderer = MarkdownRenderer(self.app, self.template)
|
||||
markdown = Markdown(renderer=renderer)
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
def render_markdown_sync(app, markdown_string):
|
||||
renderer = MarkdownRenderer(app, None)
|
||||
markdown = Markdown(renderer=renderer)
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
@time_cache_async(120)
|
||||
async def render_markdown(app, markdown_string):
|
||||
return render_markdown_sync(app, markdown_string)
|
||||
|
||||
|
||||
from jinja2 import TemplateSyntaxError, nodes
|
||||
from jinja2.ext import Extension
|
||||
from jinja2.nodes import Const
|
||||
|
||||
|
||||
# Source: https://ron.sh/how-to-write-a-jinja2-extension/
|
||||
class MarkdownExtension(Extension):
|
||||
tags = {"markdown"}
|
||||
|
||||
def __init__(self, environment):
|
||||
self.app = SimpleNamespace(jinja2_env=environment)
|
||||
super(MarkdownExtension, self).__init__(environment)
|
||||
|
||||
def parse(self, parser):
|
||||
line_number = next(parser.stream).lineno
|
||||
md_file = [Const("")]
|
||||
body = ""
|
||||
try:
|
||||
md_file = [parser.parse_expression()]
|
||||
except TemplateSyntaxError:
|
||||
body = parser.parse_statements(["name:endmarkdown"], drop_needle=True)
|
||||
return nodes.CallBlock(
|
||||
self.call_method("_to_html", md_file), [], [], body
|
||||
).set_lineno(line_number)
|
||||
|
||||
def _to_html(self, md_file, caller):
|
||||
return render_markdown_sync(self.app, caller())
|
||||
@@ -0,0 +1,43 @@
|
||||
# Written by retoor@molodetz.nl
|
||||
|
||||
# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers.
|
||||
|
||||
# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library.
|
||||
|
||||
# MIT License: This code is distributed under the MIT License.
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def no_cors_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
response.headers.pop("Access-Control-Allow-Origin", None)
|
||||
return response
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def cors_allow_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
return response
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def cors_middleware(request, handler):
|
||||
if request.method == "OPTIONS":
|
||||
response = web.Response()
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = (
|
||||
"GET, POST, PUT, DELETE, OPTIONS"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
return response
|
||||
|
||||
response = await handler(request)
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
return response
|
||||
@@ -0,0 +1,373 @@
|
||||
# Written by retoor@molodetz.nl
|
||||
|
||||
# The script defines a flexible validation and field management system for models, with capabilities for setting attributes, validation, error handling, and JSON conversion. It includes classes for managing various field types with specific properties such as UUID, timestamps for creation and updates, and custom validation rules.
|
||||
|
||||
# This script utilizes external Python libraries such as 're' for regex operations, 'uuid' for generating unique identifiers, and 'json' for data interchange. The 'datetime' and 'timezone' modules from the Python standard library are used for date and time operations. 'OrderedDict' from 'collections' provides enhanced dictionary capabilities, and 'copy' allows deep copying of objects.
|
||||
|
||||
# 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:
|
||||
#
|
||||
# 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.
|
||||
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
TIMESTAMP_REGEX = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2}$"
|
||||
|
||||
|
||||
def now():
|
||||
return str(datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def add_attrs(**kwargs):
|
||||
def decorator(func):
|
||||
for key, value in kwargs.items():
|
||||
setattr(func, key, value)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def validate_attrs(
|
||||
required=False, min_length=None, max_length=None, regex=None, **kwargs
|
||||
):
|
||||
def decorator(func):
|
||||
return add_attrs(
|
||||
required=required,
|
||||
min_length=min_length,
|
||||
max_length=max_length,
|
||||
regex=regex,
|
||||
**kwargs,
|
||||
)(func)
|
||||
|
||||
|
||||
class Validator:
|
||||
_index = 0
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, val):
|
||||
self._value = json.loads(json.dumps(val, default=str))
|
||||
|
||||
@property
|
||||
def initial_value(self):
|
||||
return self.value
|
||||
|
||||
def custom_validation(self):
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
required=False,
|
||||
min_num=None,
|
||||
max_num=None,
|
||||
min_length=None,
|
||||
max_length=None,
|
||||
regex=None,
|
||||
value=None,
|
||||
kind=None,
|
||||
help_text=None,
|
||||
app=None,
|
||||
model=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.index = Validator._index
|
||||
Validator._index += 1
|
||||
self.app = app
|
||||
self.model = model
|
||||
self.required = required
|
||||
self.min_num = min_num
|
||||
self.max_num = max_num
|
||||
self.min_length = min_length
|
||||
self.max_length = max_length
|
||||
self.regex = regex
|
||||
self._value = None
|
||||
self.value = value
|
||||
self.kind = kind
|
||||
self.help_text = help_text
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
error_list = []
|
||||
if self.value is None and self.required:
|
||||
error_list.append("Field is required.")
|
||||
return error_list
|
||||
|
||||
if self.value is None:
|
||||
return error_list
|
||||
|
||||
if self.kind in [int, float]:
|
||||
if self.min_num is not None and self.value < self.min_num:
|
||||
error_list.append(f"Field should be minimal {self.min_num}.")
|
||||
if self.max_num is not None and self.value > self.max_num:
|
||||
error_list.append(f"Field should be maximal {self.max_num}.")
|
||||
if self.min_length is not None and len(self.value) < self.min_length:
|
||||
error_list.append(
|
||||
f"Field should be minimal {self.min_length} characters long."
|
||||
)
|
||||
if self.max_length is not None and len(self.value) > self.max_length:
|
||||
error_list.append(
|
||||
f"Field should be maximal {self.max_length} characters long."
|
||||
)
|
||||
if self.regex and self.value and not re.match(self.regex, self.value):
|
||||
error_list.append("Invalid value.")
|
||||
if self.kind and not isinstance(self.value, self.kind):
|
||||
error_list.append(f"Invalid kind. It is supposed to be {self.kind}.")
|
||||
return error_list
|
||||
|
||||
async def validate(self):
|
||||
errors = await self.errors
|
||||
if errors:
|
||||
raise ValueError(f"Errors: {errors}.")
|
||||
return True
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
try:
|
||||
await self.validate()
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def to_json(self):
|
||||
errors = await self.errors
|
||||
is_valid = await self.is_valid
|
||||
return {
|
||||
"required": self.required,
|
||||
"min_num": self.min_num,
|
||||
"max_num": self.max_num,
|
||||
"min_length": self.min_length,
|
||||
"max_length": self.max_length,
|
||||
"regex": self.regex,
|
||||
"value": self.value,
|
||||
"kind": str(self.kind),
|
||||
"help_text": self.help_text,
|
||||
"errors": errors,
|
||||
"is_valid": is_valid,
|
||||
"index": self.index,
|
||||
}
|
||||
|
||||
|
||||
class ModelField(Validator):
|
||||
|
||||
index = 1
|
||||
|
||||
def __init__(self, name=None, save=True, *args, **kwargs):
|
||||
self.name = name
|
||||
self.save = save
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def to_json(self):
|
||||
result = await super().to_json()
|
||||
result["name"] = self.name
|
||||
return result
|
||||
|
||||
|
||||
class CreatedField(ModelField):
|
||||
|
||||
@property
|
||||
def initial_value(self):
|
||||
return now()
|
||||
|
||||
def update(self):
|
||||
if not self.value:
|
||||
self.value = now()
|
||||
|
||||
|
||||
class UpdatedField(ModelField):
|
||||
|
||||
def update(self):
|
||||
self.value = now()
|
||||
|
||||
|
||||
class DeletedField(ModelField):
|
||||
|
||||
def update(self):
|
||||
self.value = now()
|
||||
|
||||
|
||||
class UUIDField(ModelField):
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return str(self._value)
|
||||
|
||||
@value.setter
|
||||
def value(self, val):
|
||||
self._value = str(val)
|
||||
|
||||
@property
|
||||
def initial_value(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
||||
uid = UUIDField(name="uid", required=True)
|
||||
created_at = CreatedField(
|
||||
name="created_at",
|
||||
required=True,
|
||||
regex=TIMESTAMP_REGEX,
|
||||
place_holder="Created at",
|
||||
)
|
||||
updated_at = UpdatedField(
|
||||
name="updated_at", regex=TIMESTAMP_REGEX, place_holder="Updated at"
|
||||
)
|
||||
deleted_at = DeletedField(
|
||||
name="deleted_at", regex=TIMESTAMP_REGEX, place_holder="Deleted at"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def from_record(cls, record, mapper):
|
||||
model = cls()
|
||||
model.mapper = mapper
|
||||
model.record = record
|
||||
return model
|
||||
|
||||
@property
|
||||
def mapper(self):
|
||||
return self._mapper
|
||||
|
||||
@mapper.setter
|
||||
def mapper(self, value):
|
||||
self._mapper = value
|
||||
|
||||
@property
|
||||
def record(self):
|
||||
return {key: field.value for key, field in self.fields.items()}
|
||||
|
||||
@record.setter
|
||||
def record(self, val):
|
||||
for key, value in val.items():
|
||||
field = self.fields.get(key)
|
||||
if not field:
|
||||
continue
|
||||
self[key] = value
|
||||
return self
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._mapper = None
|
||||
self.fields = {}
|
||||
for key in dir(self.__class__):
|
||||
obj = getattr(self.__class__, key)
|
||||
|
||||
if isinstance(obj, Validator):
|
||||
self.__dict__[key] = copy.deepcopy(obj)
|
||||
self.__dict__[key].value = kwargs.pop(
|
||||
key, self.__dict__[key].initial_value
|
||||
)
|
||||
self.fields[key] = self.__dict__[key]
|
||||
self.fields[key].model = self
|
||||
self.fields[key].app = kwargs.get("app")
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
obj = self.__dict__.get(key)
|
||||
if isinstance(obj, Validator):
|
||||
obj.value = value
|
||||
|
||||
def __getattr__(self, key):
|
||||
obj = self.__dict__.get(key)
|
||||
if isinstance(obj, Validator):
|
||||
return obj.value
|
||||
return obj
|
||||
|
||||
def set_user_data(self, data):
|
||||
for key, value in data.items():
|
||||
field = self.fields.get(key)
|
||||
if not field:
|
||||
continue
|
||||
if value.get("name"):
|
||||
value = value.get("value")
|
||||
field.value = value
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
return all([await field.is_valid for field in self.fields.values()])
|
||||
|
||||
def __getitem__(self, key):
|
||||
obj = self.__dict__.get(key)
|
||||
if isinstance(obj, Validator):
|
||||
return obj.value
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
obj = getattr(self, key)
|
||||
if isinstance(obj, Validator):
|
||||
obj.value = value
|
||||
else:
|
||||
self.__dict__[key] = value
|
||||
|
||||
@property
|
||||
async def recordz(self):
|
||||
obj = await self.to_json()
|
||||
record = {}
|
||||
for key, value in obj.items():
|
||||
if not isinstance(value, dict) or "value" not in value:
|
||||
continue
|
||||
if getattr(self, key).save:
|
||||
record[key] = value.get("value")
|
||||
return record
|
||||
|
||||
async def to_json(self, encode=False):
|
||||
model_data = OrderedDict(
|
||||
{
|
||||
"uid": self.uid.value,
|
||||
"created_at": self.created_at.value,
|
||||
"updated_at": self.updated_at.value,
|
||||
"deleted_at": self.deleted_at.value,
|
||||
"is_valid": await self.is_valid,
|
||||
}
|
||||
)
|
||||
|
||||
for key, value in self.fields.items():
|
||||
if key == "record":
|
||||
continue
|
||||
value = self.__dict__[key]
|
||||
if hasattr(value, "value"):
|
||||
model_data[key] = await value.to_json()
|
||||
if encode:
|
||||
return json.dumps(model_data, indent=2)
|
||||
return model_data
|
||||
|
||||
|
||||
class FormElement(ModelField):
|
||||
|
||||
def __init__(self, place_holder=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.place_holder = place_holder
|
||||
|
||||
|
||||
class FormElement(ModelField):
|
||||
|
||||
def __init__(self, place_holder=None, *args, **kwargs):
|
||||
self.place_holder = place_holder
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def to_json(self):
|
||||
data = await super().to_json()
|
||||
data["name"] = self.name
|
||||
data["place_holder"] = self.place_holder
|
||||
return data
|
||||
@@ -0,0 +1,13 @@
|
||||
class Object:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for arg in args:
|
||||
if isinstance(arg, dict):
|
||||
self.__dict__.update(arg)
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.__dict__[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__dict__[key] = value
|
||||
@@ -0,0 +1,22 @@
|
||||
import hashlib
|
||||
|
||||
DEFAULT_SALT = b"snekker-de-snek-"
|
||||
|
||||
|
||||
async def hash(data, salt=DEFAULT_SALT):
|
||||
try:
|
||||
data = data.encode(errors="ignore")
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
salt = salt.encode(errors="ignore")
|
||||
except AttributeError:
|
||||
pass
|
||||
salted = salt + data
|
||||
|
||||
obj = hashlib.sha256(salted)
|
||||
return obj.hexdigest()
|
||||
|
||||
|
||||
async def verify(string: str, hashed: str):
|
||||
return await hash(string) == hashed
|
||||
@@ -0,0 +1,67 @@
|
||||
from snek.mapper import get_mapper
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class BaseService:
|
||||
|
||||
mapper_name: BaseMapper = None
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
return self.app.services
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.cache = app.cache
|
||||
if self.mapper_name:
|
||||
self.mapper = get_mapper(self.mapper_name, app=self.app)
|
||||
else:
|
||||
self.mapper = None
|
||||
|
||||
async def exists(self, uid=None, **kwargs):
|
||||
if uid:
|
||||
if not kwargs and await self.cache.get(uid):
|
||||
return True
|
||||
kwargs["uid"] = uid
|
||||
return await self.count(**kwargs) > 0
|
||||
|
||||
async def count(self, **kwargs):
|
||||
return await self.mapper.count(**kwargs)
|
||||
|
||||
async def new(self, **kwargs):
|
||||
return await self.mapper.new()
|
||||
|
||||
async def query(self, sql, *args):
|
||||
for record in self.app.db.query(sql, *args):
|
||||
yield record
|
||||
|
||||
async def get(self, uid=None, **kwargs):
|
||||
if uid:
|
||||
if not kwargs:
|
||||
result = await self.cache.get(uid)
|
||||
if result:
|
||||
return result
|
||||
kwargs["uid"] = uid
|
||||
|
||||
result = await self.mapper.get(**kwargs)
|
||||
if result:
|
||||
await self.cache.set(result["uid"], result)
|
||||
return result
|
||||
|
||||
async def save(self, model: UserModel):
|
||||
# if model.is_valid: You Know why not
|
||||
if await self.mapper.save(model):
|
||||
await self.cache.set(model["uid"], model)
|
||||
return True
|
||||
errors = await model.errors
|
||||
raise Exception(f"Couldn't save model. Errors: f{errors}")
|
||||
|
||||
async def find(self, **kwargs):
|
||||
if not "_limit" in kwargs or int(kwargs.get("_limit")) > 30:
|
||||
kwargs["_limit"] = 60
|
||||
async for model in self.mapper.find(**kwargs):
|
||||
yield model
|
||||
|
||||
async def delete(self, **kwargs):
|
||||
return await self.mapper.delete(**kwargs)
|
||||
@@ -0,0 +1,69 @@
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.markdown import render_markdown
|
||||
|
||||
|
||||
class BaseView(web.View):
|
||||
|
||||
login_required = False
|
||||
|
||||
async def _iter(self):
|
||||
if self.login_required and not self.session.get("logged_in"):
|
||||
return web.HTTPFound("/")
|
||||
return await super()._iter()
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
return self.request.app
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.app.db
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
return self.app.services
|
||||
|
||||
async def json_response(self, data, **kwargs):
|
||||
return web.json_response(data, **kwargs)
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return self.request.session
|
||||
|
||||
async def render_template(self, template_name, context=None):
|
||||
if template_name.endswith(".md"):
|
||||
response = await self.request.app.render_template(
|
||||
template_name, self.request, context
|
||||
)
|
||||
body = await render_markdown(self.app, response.body.decode())
|
||||
return web.Response(body=body, content_type="text/html")
|
||||
return await self.request.app.render_template(
|
||||
template_name, self.request, context
|
||||
)
|
||||
|
||||
|
||||
class BaseFormView(BaseView):
|
||||
|
||||
form = None
|
||||
|
||||
async def get(self):
|
||||
form = self.form(app=self.app)
|
||||
|
||||
return await self.json_response(await form.to_json())
|
||||
|
||||
async def post(self):
|
||||
form = self.form(app=self.app)
|
||||
post = await self.request.json()
|
||||
form.set_user_data(post["form"])
|
||||
result = await form.to_json()
|
||||
if post.get("action") == "validate":
|
||||
# Pass
|
||||
pass
|
||||
if post.get("action") == "submit" and result["is_valid"]:
|
||||
result = await self.submit(form)
|
||||
return await self.json_response(result)
|
||||
return await self.json_response(result)
|
||||
|
||||
async def submit(self, model=None):
|
||||
print("Submit sucess")
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<div class="dialog">
|
||||
|
||||
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
|
||||
<html-frame url="/about.md"></html-frame>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Snek
|
||||
|
||||
## What is a snek?
|
||||
A snek is a danger noodle.
|
||||
|
||||
## Design choices
|
||||
I made several design choices:
|
||||
- Implemented **the worst 3rd party markdown to html renderer ever**. See this nice *bullet list*.
|
||||
- Only password requirement is thats it requires six characters. Users are responsibly for their own security. Snek is not so arrogant to determine if a password is strong enough. It's up to what user prefers. Snek does not have a forgot-my-password service tho.
|
||||
- Email is not required for registration. Email is (maybe) used in future for resetting password.
|
||||
- Database is SQLite by default. Postgres is also possible. In that case you have to change `db_path` prefix to `postgres:///` and add a postgres docker container to the docker setup.
|
||||
- Homebrew made ORM framework based on dataset.
|
||||
- Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure.
|
||||
- !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.
|
||||
- No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/message-list.js"></script>
|
||||
<style>{{ highlight_styles }}</style>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/fancy-button.js"></script>
|
||||
<script src="/html-frame.js"></script>
|
||||
<script src="/generic-form.js"></script>
|
||||
<link rel="stylesheet" href="/html-frame.css"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{% block header %}
|
||||
{% endblock %}
|
||||
|
||||
</header>
|
||||
<main>
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<script src="/fancy-button.js"></script>
|
||||
<link rel="stylesheet" href="/base.css">
|
||||
<link rel="stylesheet" href="/generic-form.css">
|
||||
<script src="/html-frame.js"></script>
|
||||
<script src="/generic-form.js"></script>
|
||||
<link rel="stylesheet" href="/html-frame.css"></script>
|
||||
<link rel="stylesheet" href="/register__.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{% block header %}
|
||||
{% endblock %}
|
||||
|
||||
</header>
|
||||
<main>
|
||||
<aside class="sidebar">
|
||||
{% block sidebar %}
|
||||
|
||||
{% endblock %}
|
||||
</aside>
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<div class="dialog">
|
||||
|
||||
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
|
||||
<html-frame url="/docs.md"></html-frame>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
# API Documentation
|
||||
|
||||
Currently only some details about the internal API are available.
|
||||
|
||||
## How to create a user
|
||||
```python
|
||||
# Save user to the table named 'user'
|
||||
# Password gets sha256 encrypted with default a salt string
|
||||
# of the snek.system.security module.
|
||||
|
||||
new_user_object = await app.service.user.register(
|
||||
username="retoor", password="retoorded"
|
||||
)
|
||||
```
|
||||
|
||||
## Encrypt string
|
||||
```python
|
||||
from snek.system import security
|
||||
|
||||
# Support for both utf and bytes.
|
||||
var1 = security.encrypt("data")
|
||||
var2 = security.encrypt(b"data")
|
||||
|
||||
# Is correct:
|
||||
assert var1 == var2
|
||||
```
|
||||
|
||||
## How to create a basic HTML / Markdown view
|
||||
```python
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class IndexView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
# The render function supports markdown.
|
||||
# It will render with syntax highlighting.
|
||||
# Just use the .md file extension in the file name.
|
||||
return await self.render("index.html")
|
||||
```
|
||||
## How to create a FormView
|
||||
```python
|
||||
from snek.form.register import RegisterForm
|
||||
from snek.system.view import BaseFormView
|
||||
|
||||
|
||||
class RegisterFormView(BaseFormView):
|
||||
|
||||
form = RegisterForm
|
||||
```
|
||||
## How to register a class view
|
||||
```python
|
||||
app.routes.add_view("/your-page.html", YourViewClass)
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Snek chat by Molodetz</title>
|
||||
<link rel="stylesheet" href="generic-form.css">
|
||||
<link rel="stylesheet" href="register__.css">
|
||||
<script src="/fancy-button.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="registration-container">
|
||||
<h1>Snek</h1>
|
||||
<fancy-button url="/login.html" text="Login"></fancy-button>
|
||||
<span style="padding:10px;">Or</span>
|
||||
<fancy-button url="/register.html" text="Register"></fancy-button>
|
||||
<a href="/about.html">Design choices</a>
|
||||
<a href="/web.html">App preview</a>
|
||||
<a href="/docs/docs/">API docs</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<fancy-button url="/back" text="Back" size="auto"></fancy-button>
|
||||
<generic-form class="center" url="/login.json"></generic-form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
<style>
|
||||
{{highlight_styles}}
|
||||
</style>
|
||||
{#
|
||||
<div data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" data-message="{{message}}" class="message"><div class="avatar" style="background-color: {{color}}; color: black;">{{user_nick[0]}}</div><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">
|
||||
#}
|
||||
{% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}
|
||||
{#}
|
||||
</div><div class="time">{{created_at}}</div></div></div>
|
||||
{#}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<fancy-button url="/back" text="Back" size="auto"></fancy-button>
|
||||
|
||||
<generic-form class="center" url="/register.json"></generic-form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dynamic Form Component</title>
|
||||
<style>
|
||||
.form-container {
|
||||
max-width: 400px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.form-field {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-field label {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.form-field input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-field .error {
|
||||
color: red;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Use the custom form component -->
|
||||
<dynamic-form></dynamic-form>
|
||||
|
||||
<script>
|
||||
class DynamicForm extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Sample data for the form
|
||||
const formData = {
|
||||
form: {
|
||||
uid: { required: true, value: "e13ad3b7-20b2-4c1a-b74e-b8c7d1abd107", html_type: "text", place_holder: "UID", is_valid: true },
|
||||
created_at: { required: true, value: "2025-01-17 21:21:27.561769+00:00", html_type: "text", place_holder: "Created At", is_valid: true },
|
||||
updated_at: { required: false, value: null, html_type: "text", place_holder: "Updated At", is_valid: true },
|
||||
deleted_at: { required: false, value: null, html_type: "text", place_holder: "Deleted At", is_valid: true },
|
||||
email: { required: true, value: null, html_type: "email", place_holder: "Email address", errors: ["Field is required."], is_valid: false },
|
||||
password: { required: true, value: null, html_type: "password", place_holder: "Password", errors: ["Field is required."], is_valid: false },
|
||||
username: { required: true, value: null, html_type: "text", place_holder: "Username", errors: ["Field is required."], is_valid: false }
|
||||
}
|
||||
};
|
||||
|
||||
// Render the form
|
||||
this.render(formData);
|
||||
}
|
||||
|
||||
render(data) {
|
||||
const form = data.form;
|
||||
const container = document.createElement('div');
|
||||
container.className = 'form-container';
|
||||
|
||||
// Create a form element
|
||||
const formElement = document.createElement('form');
|
||||
|
||||
// Generate form fields from the data
|
||||
Object.entries(form).forEach(([fieldName, fieldData]) => {
|
||||
const fieldContainer = document.createElement('div');
|
||||
fieldContainer.className = 'form-field';
|
||||
|
||||
// Add label
|
||||
const label = document.createElement('label');
|
||||
label.textContent = fieldName.replace(/_/g, ' ').toUpperCase();
|
||||
label.htmlFor = fieldName;
|
||||
fieldContainer.appendChild(label);
|
||||
|
||||
// Add input field
|
||||
const input = document.createElement('input');
|
||||
input.type = fieldData.html_type || 'text';
|
||||
input.name = fieldName;
|
||||
input.value = fieldData.value || '';
|
||||
input.placeholder = fieldData.place_holder || '';
|
||||
input.required = fieldData.required || false;
|
||||
|
||||
// Append input to the container
|
||||
fieldContainer.appendChild(input);
|
||||
|
||||
// Display validation errors
|
||||
if (fieldData.errors && fieldData.errors.length > 0) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = fieldData.errors.join(', ');
|
||||
fieldContainer.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
// Append field to the form
|
||||
formElement.appendChild(fieldContainer);
|
||||
});
|
||||
|
||||
// Add the form to the container
|
||||
container.appendChild(formElement);
|
||||
|
||||
// Append the container to the shadow DOM
|
||||
this.shadowRoot.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
// Define the custom element
|
||||
customElements.define('dynamic-form', DynamicForm);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Snek</title>
|
||||
<script src="/html-frame.js"></script>
|
||||
<script src="/schedule.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/models.js"></script>
|
||||
<script src="/message-list.js"></script>
|
||||
<script src="/message-list-manager.js"></script>
|
||||
<script src="/chat-input.js"></script>
|
||||
<script src="/chat-window.js"></script>
|
||||
<link rel="stylesheet" href="/base.css">
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/image/snek1.png" sizes="32x32">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Snek</div>
|
||||
<nav>
|
||||
<a href="/web.html">Home</a>
|
||||
<a style="display:none" id="install-button" href="#">Install</a>
|
||||
<a href="#">Settings</a>
|
||||
<a href="/logout.html">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<aside class="sidebar">
|
||||
<h2>Chat Rooms</h2>
|
||||
<ul>
|
||||
<li><a href="#">General</a></li>
|
||||
<li><a href="#">Development</a></li>
|
||||
<li><a href="#">Support</a></li>
|
||||
<li><a href="#">Random</a></li>
|
||||
|
||||
</ul>
|
||||
</aside>
|
||||
<chat-window class="chat-area"></chat-window>
|
||||
</main>
|
||||
<script>
|
||||
let installPrompt = null
|
||||
window.addEventListener("beforeinstallprompt", async(event) => {
|
||||
event.preventDefault();
|
||||
installPrompt = event;
|
||||
//document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const button = document.getElementById("install-button")
|
||||
button.addEventListener("click", async ()=>{
|
||||
const result = await installPrompt.prompt()
|
||||
console.info(result.outcome)
|
||||
})
|
||||
button.style.display = 'block'
|
||||
|
||||
});
|
||||
;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class AboutHTMLView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("about.html")
|
||||
|
||||
|
||||
class AboutMDView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("about.md")
|
||||
@@ -0,0 +1,13 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class DocsHTMLView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("docs.html")
|
||||
|
||||
|
||||
class DocsMDView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("docs.md")
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class IndexView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("index.html")
|
||||
@@ -0,0 +1,27 @@
|
||||
from aiohttp import web
|
||||
|
||||
from snek.form.login import LoginForm
|
||||
from snek.system.view import BaseFormView
|
||||
|
||||
|
||||
class LoginView(BaseFormView):
|
||||
form = LoginForm
|
||||
|
||||
async def get(self):
|
||||
if self.session.get("logged_in"):
|
||||
return web.HTTPFound("/web.html")
|
||||
if self.request.path.endswith(".json"):
|
||||
return await super().get()
|
||||
return await self.render_template("login.html")
|
||||
|
||||
async def submit(self, form):
|
||||
if await form.is_valid:
|
||||
user = await self.services.user.get(username=form['username'],deleted_at=None)
|
||||
# Migrate data
|
||||
await self.services.user.save(user)
|
||||
self.session["logged_in"] = True
|
||||
self.session["username"] = user['username']
|
||||
self.session["uid"] = user["uid"]
|
||||
self.session["color"] = user["color"]
|
||||
return {"redirect_url": "/web.html"}
|
||||
return {"is_valid": False}
|
||||
@@ -0,0 +1,14 @@
|
||||
from snek.form.login import LoginForm
|
||||
from snek.system.view import BaseFormView
|
||||
|
||||
|
||||
class LoginFormView(BaseFormView):
|
||||
form = LoginForm
|
||||
|
||||
async def submit(self, form):
|
||||
if await form.is_valid:
|
||||
self.session["logged_in"] = True
|
||||
self.session["username"] = form.username.value
|
||||
self.session["uid"] = form.uid.value
|
||||
return {"redirect_url": "/web.html"}
|
||||
return {"is_valid": False}
|
||||
@@ -0,0 +1,27 @@
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class LogoutView(BaseView):
|
||||
|
||||
redirect_url = "/"
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
try:
|
||||
del self.session["logged_in"]
|
||||
del self.session["uid"]
|
||||
del self.session["username"]
|
||||
except KeyError:
|
||||
pass
|
||||
return web.HTTPFound(self.redirect_url)
|
||||
|
||||
async def post(self):
|
||||
try:
|
||||
del self.session["logged_in"]
|
||||
del self.session["uid"]
|
||||
del self.session["username"]
|
||||
except KeyError:
|
||||
pass
|
||||
return await self.json_response({"redirect_url": self.redirect_url})
|
||||
@@ -0,0 +1,26 @@
|
||||
from aiohttp import web
|
||||
|
||||
from snek.form.register import RegisterForm
|
||||
from snek.system.view import BaseFormView
|
||||
|
||||
|
||||
class RegisterView(BaseFormView):
|
||||
|
||||
form = RegisterForm
|
||||
|
||||
async def get(self):
|
||||
if self.session.get("logged_in"):
|
||||
return web.HTTPFound("/web.html")
|
||||
if self.request.path.endswith(".json"):
|
||||
return await super().get()
|
||||
return await self.render_template("register.html")
|
||||
|
||||
async def submit(self, form):
|
||||
result = await self.app.services.user.register(
|
||||
form.email.value, form.username.value, form.password.value
|
||||
)
|
||||
self.request.session["uid"] = result["uid"]
|
||||
self.request.session["username"] = result["username"]
|
||||
self.request.session["logged_in"] = True
|
||||
self.request.session["color"] = result["color"]
|
||||
return {"redirect_url": "/web.html"}
|
||||
@@ -0,0 +1,16 @@
|
||||
from snek.form.register import RegisterForm
|
||||
from snek.system.view import BaseFormView
|
||||
|
||||
|
||||
class RegisterFormView(BaseFormView):
|
||||
form = RegisterForm
|
||||
|
||||
async def submit(self, form):
|
||||
result = await self.app.services.user.register(
|
||||
form.email.value, form.username.value, form.password.value
|
||||
)
|
||||
self.request.session["uid"] = result["uid"]
|
||||
self.request.session["username"] = result["usernmae"]
|
||||
self.request.session["logged_in"] = True
|
||||
|
||||
return {"redirect_url": "/web.html"}
|
||||
@@ -0,0 +1,147 @@
|
||||
from aiohttp import web
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class RPCView(BaseView):
|
||||
|
||||
class RPCApi:
|
||||
def __init__(self,view, ws):
|
||||
self.view = view
|
||||
self.app = self.view.app
|
||||
self.services = self.app.services
|
||||
self.ws = ws
|
||||
|
||||
@property
|
||||
def user_uid(self):
|
||||
return self.view.session.get("uid")
|
||||
|
||||
|
||||
@property
|
||||
def request(self):
|
||||
return self.view.request
|
||||
|
||||
def _require_login(self):
|
||||
if not self.is_logged_in:
|
||||
raise Exception("Not logged in")
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
return self.view.session.get("logged_in", False)
|
||||
|
||||
async def login(self, username, password):
|
||||
success = await self.services.user.validate_login(username, password)
|
||||
if not success:
|
||||
raise Exception("Invalid username or password")
|
||||
user = await self.services.user.get(username=username)
|
||||
self.view.session["uid"] = user["uid"]
|
||||
self.view.session["logged_in"] = True
|
||||
self.view.session["username"] = user["username"]
|
||||
self.view.session["user_nick"] = user["nick"]
|
||||
record = user.record
|
||||
del record['password']
|
||||
del record['deleted_at']
|
||||
await self.services.socket.add(self.ws)
|
||||
async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get("uid"),deleted_at=None,is_banned=False):
|
||||
await self.services.socket.subscribe(self.ws,subscription["channel_uid"])
|
||||
|
||||
return record
|
||||
async def get_user(self, user_uid):
|
||||
self._require_login()
|
||||
if not user_uid:
|
||||
user_uid = self.user_uid
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
record = user.record
|
||||
del record['password']
|
||||
del record['deleted_at']
|
||||
if not user_uid == user["uid"]:
|
||||
del record['email']
|
||||
return record
|
||||
async def get_messages(self, channel_uid,offset=0):
|
||||
self._require_login()
|
||||
messages = []
|
||||
async for message in self.services.channel_message.query("SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 60"): #"SELECT uid, channel_uid, user_uid, message, created_at FROM channel_message WHERE channel_uid = :channel_uid ORDER BY created_at DESC LIMIT 30 OFFSET :offset",{"channel_uid":channel_uid,"offset":int(offset)}):
|
||||
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
if not user:
|
||||
print("User not found!",flush= True)
|
||||
continue
|
||||
|
||||
messages.insert(0,dict(
|
||||
uid=message["uid"],
|
||||
color=user['color'],
|
||||
user_uid=message["user_uid"],
|
||||
channel_uid=message["channel_uid"],
|
||||
user_nick=user['nick'],
|
||||
message=message["message"],
|
||||
created_at=message["created_at"],
|
||||
html=message['html']
|
||||
))
|
||||
return messages
|
||||
|
||||
async def get_channels(self):
|
||||
self._require_login()
|
||||
channels = []
|
||||
async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):
|
||||
channels.append(dict(
|
||||
name=subscription["label"],
|
||||
uid=subscription["channel_uid"],
|
||||
is_moderator=subscription["is_moderator"],
|
||||
is_read_only=subscription["is_read_only"]
|
||||
))
|
||||
return channels
|
||||
|
||||
async def send_message(self, room, message):
|
||||
self._require_login()
|
||||
await self.services.chat.send(self.user_uid,room,message)
|
||||
return True
|
||||
|
||||
|
||||
async def echo(self,*args):
|
||||
self._require_login()
|
||||
return args
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def __call__(self, data):
|
||||
try:
|
||||
call_id = data.get("callId")
|
||||
method_name = data.get("method")
|
||||
if method_name.startswith("_"):
|
||||
raise Exception("Not allowed")
|
||||
args = data.get("args")
|
||||
if hasattr(super(),method_name) or not hasattr(self,method_name):
|
||||
return await self.ws.send_json({"callId":call_id,"data":"Not allowed"})
|
||||
method = getattr(self,method_name.replace(".","_"),None)
|
||||
if not method:
|
||||
raise Exception("Method not found")
|
||||
result = await method(*args)
|
||||
await self.ws.send_json({"callId":call_id,"success":True,"data":result})
|
||||
except Exception as ex:
|
||||
await self.ws.send_json({"callId":call_id,"success":False,"data":str(ex)})
|
||||
|
||||
async def call_ping(self,callId,*args):
|
||||
return {"pong": args}
|
||||
|
||||
|
||||
async def get(self):
|
||||
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(self.request)
|
||||
if self.request.session.get("logged_in") is True:
|
||||
await self.services.socket.add(ws)
|
||||
async for subscription in self.services.channel_member.find(user_uid=self.request.session.get("uid"),deleted_at=None,is_banned=False):
|
||||
await self.services.socket.subscribe(ws,subscription["channel_uid"])
|
||||
print("Subscribed for: ", subscription["label"],flush=True)
|
||||
rpc = RPCView.RPCApi(self,ws)
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
await rpc(msg.json())
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
print(f"WebSocket exception {ws.exception()}")
|
||||
|
||||
await self.services.socket.delete(ws)
|
||||
print("WebSocket connection closed")
|
||||
return ws
|
||||
@@ -0,0 +1,47 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class StatusView(BaseView):
|
||||
async def get(self):
|
||||
|
||||
memberships = []
|
||||
user = {}
|
||||
|
||||
if self.session.get("uid"):
|
||||
user = await self.app.services.user.get(uid=self.session.get("uid"))
|
||||
if not user:
|
||||
return await self.json_response({"error": "User not found"}, status=404)
|
||||
async for model in self.app.services.channel_member.find(
|
||||
user_uid=self.session.get("uid"), deleted_at=None, is_banned=False
|
||||
):
|
||||
channel = await self.app.services.channel.get(uid=model["channel_uid"])
|
||||
memberships.append(
|
||||
{
|
||||
"name": channel["label"],
|
||||
"description": model["description"],
|
||||
"user_uid": model["user_uid"],
|
||||
"is_moderator": model["is_moderator"],
|
||||
"is_read_only": model["is_read_only"],
|
||||
"is_muted": model["is_muted"],
|
||||
"is_banned": model["is_banned"],
|
||||
"channel_uid": model["channel_uid"],
|
||||
"uid": model["uid"],
|
||||
}
|
||||
)
|
||||
user = {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"nick": user["nick"],
|
||||
"uid": user["uid"],
|
||||
"color": user['color'],
|
||||
"memberships": memberships,
|
||||
}
|
||||
|
||||
return await self.json_response(
|
||||
{
|
||||
"user": user,
|
||||
"cache": await self.app.cache.create_cache_key(
|
||||
self.app.cache.cache, None
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class WebView(BaseView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("web.html")
|
||||
Reference in New Issue
Block a user