This commit is contained in:
retoor 2025-08-10 00:59:11 +02:00
parent 30382b139d
commit a3ab7b0586
35 changed files with 1108 additions and 785 deletions

View File

@ -26,9 +26,9 @@ Description: Utility to load environment variables from a .env file.
""" """
import os import os
from typing import Optional
def load_env(file_path: str = '.env') -> None:
def load_env(file_path: str = ".env") -> None:
""" """
Loads environment variables from a specified file into the current process environment. Loads environment variables from a specified file into the current process environment.
@ -43,26 +43,26 @@ def load_env(file_path: str = '.env') -> None:
IOError: If an I/O error occurs during file reading. IOError: If an I/O error occurs during file reading.
""" """
try: try:
with open(file_path, 'r') as env_file: with open(file_path) as env_file:
for line in env_file: for line in env_file:
line = line.strip() line = line.strip()
# Skip empty lines and comments # Skip empty lines and comments
if not line or line.startswith('#'): if not line or line.startswith("#"):
continue continue
# Skip lines without '=' # Skip lines without '='
if '=' not in line: if "=" not in line:
continue continue
# Split into key and value at the first '=' # Split into key and value at the first '='
key, value = line.split('=', 1) key, value = line.split("=", 1)
# Set environment variable # Set environment variable
os.environ[key.strip()] = value.strip() os.environ[key.strip()] = value.strip()
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f"Environment file '{file_path}' not found.") raise FileNotFoundError(f"Environment file '{file_path}' not found.")
except IOError as e: except OSError as e:
raise IOError(f"Error reading environment file '{file_path}': {e}") raise OSError(f"Error reading environment file '{file_path}': {e}")
try: try:
load_env() load_env()
except Exception as e: except Exception:
pass pass

View File

@ -1,20 +1,20 @@
import asyncio
import pathlib import pathlib
import shutil import shutil
import sqlite3 import sqlite3
import asyncio
import click import click
from aiohttp import web from aiohttp import web
from IPython import start_ipython
from snek.shell import Shell
from snek.app import Application from snek.app import Application
from snek.shell import Shell
@click.group() @click.group()
def cli(): def cli():
pass pass
@cli.command() @cli.command()
def export(): def export():
@ -30,6 +30,7 @@ def export():
user = await app.services.user.get(uid=message["user_uid"]) user = await app.services.user.get(uid=message["user_uid"])
message["user"] = user and user["username"] or None message["user"] = user and user["username"] or None
return (message["user"] or "") + ": " + (message["text"] or "") return (message["user"] or "") + ": " + (message["text"] or "")
async def run(): async def run():
result = [] result = []
@ -49,6 +50,7 @@ def export():
with open("dump.txt", "w") as f: with open("dump.txt", "w") as f:
f.write("\n\n".join(result)) f.write("\n\n".join(result))
print("Dump written to dump.json") print("Dump written to dump.json")
asyncio.run(run()) asyncio.run(run())
@ -57,16 +59,20 @@ def statistics():
async def run(): async def run():
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")
app.services.statistics.database() app.services.statistics.database()
asyncio.run(run()) asyncio.run(run())
@cli.command() @cli.command()
def maintenance(): def maintenance():
async def run(): async def run():
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")
await app.services.container.maintenance() await app.services.container.maintenance()
await app.services.channel_message.maintenance() await app.services.channel_message.maintenance()
asyncio.run(run()) asyncio.run(run())
@cli.command() @cli.command()
@click.option( @click.option(
"--db_path", default="snek.db", help="Database to initialize if not exists." "--db_path", default="snek.db", help="Database to initialize if not exists."
@ -123,9 +129,11 @@ def serve(port, host, db_path):
def shell(db_path): def shell(db_path):
Shell(db_path).run() Shell(db_path).run()
def main(): def main():
try: try:
import sentry_sdk import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1") sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError: except ImportError:
print("Could not import sentry_sdk") print("Could not import sentry_sdk")

View File

@ -3,13 +3,13 @@ import logging
import pathlib import pathlib
import ssl import ssl
import uuid import uuid
import signal
from datetime import datetime
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from snek import snode from snek import snode
from snek.view.threads import ThreadsView
from snek.system.ads import AsyncDataSet from snek.system.ads import AsyncDataSet
from snek.view.threads import ThreadsView
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address from ipaddress import ip_address
@ -25,16 +25,21 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
from snek.forum import setup_forum
from snek.mapper import get_mappers from snek.mapper import get_mappers
from snek.service import get_services from snek.service import get_services
from snek.sgit import GitApplication from snek.sgit import GitApplication
from snek.sssh import start_ssh_server from snek.sssh import start_ssh_server
from snek.system import http from snek.system import http
from snek.system.cache import Cache from snek.system.cache import Cache
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
from snek.system.markdown import MarkdownExtension from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler from snek.system.profiler import profiler_handler
from snek.system.stats import (
create_stats_structure,
middleware as stats_middleware,
stats_handler,
)
from snek.system.template import ( from snek.system.template import (
EmojiExtension, EmojiExtension,
LinkifyExtension, LinkifyExtension,
@ -43,10 +48,15 @@ from snek.system.template import (
) )
from snek.view.about import AboutHTMLView, AboutMDView from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView from snek.view.channel import (
ChannelAttachmentUploadView,
ChannelAttachmentView,
ChannelDriveApiView,
ChannelView,
)
from snek.view.container import ContainerView
from snek.view.docs import DocsHTMLView, DocsMDView from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveApiView, DriveView from snek.view.drive import DriveApiView, DriveView
from snek.view.channel import ChannelDriveApiView
from snek.view.index import IndexView from snek.view.index import IndexView
from snek.view.login import LoginView from snek.view.login import LoginView
from snek.view.logout import LogoutView from snek.view.logout import LogoutView
@ -55,7 +65,6 @@ from snek.view.register import RegisterView
from snek.view.repository import RepositoryView from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView from snek.view.search_user import SearchUserView
from snek.view.container import ContainerView
from snek.view.settings.containers import ( from snek.view.settings.containers import (
ContainersCreateView, ContainersCreateView,
ContainersDeleteView, ContainersDeleteView,
@ -77,7 +86,6 @@ from snek.view.upload import UploadView
from snek.view.user import UserView from snek.view.user import UserView
from snek.view.web import WebView from snek.view.web import WebView
from snek.webdav import WebdavApplication from snek.webdav import WebdavApplication
from snek.forum import setup_forum
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes from snek.system.template import whitelist_attributes
@ -175,13 +183,12 @@ class Application(BaseApplication):
self.on_startup.append(self.prepare_asyncio) self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
#self.on_startup.append(self.prepare_database) # self.on_startup.append(self.prepare_database)
async def prepare_stats(self, app): async def prepare_stats(self, app):
app['stats'] = create_stats_structure() app["stats"] = create_stats_structure()
print("Stats prepared", flush=True) print("Stats prepared", flush=True)
@property @property
def uptime_seconds(self): def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds() return (datetime.now() - self.time_start).total_seconds()
@ -225,10 +232,10 @@ class Application(BaseApplication):
# app.loop = asyncio.get_running_loop() # app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200) app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor) app.loop.set_default_executor(self.executor)
#for sig in (signal.SIGINT, signal.SIGTERM): # for sig in (signal.SIGINT, signal.SIGTERM):
#app.loop.add_signal_handler( # app.loop.add_signal_handler(
# sig, lambda: asyncio.create_task(self.services.container.shutdown()) # sig, lambda: asyncio.create_task(self.services.container.shutdown())
#) # )
async def create_task(self, task): async def create_task(self, task):
await self.tasks.put(task) await self.tasks.put(task)
@ -244,7 +251,6 @@ class Application(BaseApplication):
print(ex) print(ex)
self.db.commit() self.db.commit()
async def prepare_database(self, app): async def prepare_database(self, app):
await self.db.query_raw("PRAGMA journal_mode=WAL") await self.db.query_raw("PRAGMA journal_mode=WAL")
await self.db.query_raw("PRAGMA syncnorm=off") await self.db.query_raw("PRAGMA syncnorm=off")
@ -291,9 +297,7 @@ class Application(BaseApplication):
self.router.add_view( self.router.add_view(
"/channel/{channel_uid}/attachment.bin", ChannelAttachmentView "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
) )
self.router.add_view( self.router.add_view("/channel/{channel_uid}/drive.json", ChannelDriveApiView)
"/channel/{channel_uid}/drive.json", ChannelDriveApiView
)
self.router.add_view( self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView "/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
) )
@ -414,9 +418,9 @@ class Application(BaseApplication):
) )
try: try:
context["nonce"] = request['csp_nonce'] context["nonce"] = request["csp_nonce"]
except: except:
context['nonce'] = '?' context["nonce"] = "?"
rendered = await super().render_template(template, request, context) rendered = await super().render_template(template, request, context)
@ -466,14 +470,14 @@ class Application(BaseApplication):
@asynccontextmanager @asynccontextmanager
async def no_save(self): async def no_save(self):
stats = { stats = {"count": 0}
'count': 0
}
async def patched_save(*args, **kwargs): async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0]) await self.cache.set(args[0]["uid"], args[0])
stats['count'] = stats['count'] + 1 stats["count"] = stats["count"] + 1
print(f"save is ignored {stats['count']} times") print(f"save is ignored {stats['count']} times")
return args[0] return args[0]
save_original = self.services.channel_message.mapper.save save_original = self.services.channel_message.mapper.save
self.services.channel_message.mapper.save = patched_save self.services.channel_message.mapper.save = patched_save
raised_exception = None raised_exception = None
@ -486,6 +490,7 @@ class Application(BaseApplication):
if raised_exception: if raised_exception:
raise raised_exception raise raised_exception
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")

View File

@ -1,5 +1,6 @@
# forum_app.py # forum_app.py
import aiohttp.web import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
@ -7,13 +8,13 @@ class ForumApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs): def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.parent = parent self.parent = parent
self.render_template = self.parent.render_template self.render_template = self.parent.render_template
# Set up routes # Set up routes
self.setup_routes() self.setup_routes()
# Set up notification listeners # Set up notification listeners
self.setup_notifications() self.setup_notifications()
@property @property
def db(self): def db(self):
return self.parent.db return self.parent.db
@ -21,86 +22,84 @@ class ForumApplication(aiohttp.web.Application):
@property @property
def services(self): def services(self):
return self.parent.services return self.parent.services
def setup_routes(self): def setup_routes(self):
"""Set up all forum routes""" """Set up all forum routes"""
# API routes # API routes
self.router.add_view("/index.html", ForumIndexView) self.router.add_view("/index.html", ForumIndexView)
self.router.add_route("GET", "/api/forums", ForumView.get_forums) self.router.add_route("GET", "/api/forums", ForumView.get_forums)
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum) self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread) self.router.add_route(
"POST", "/api/forums/{slug}/threads", ForumView.create_thread
)
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread) self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post) self.router.add_route(
"POST", "/api/threads/{thread_uid}/posts", ForumView.create_post
)
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post) self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post) self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like) self.router.add_route(
self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin) "POST", "/api/posts/{post_uid}/like", ForumView.toggle_like
self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock) )
self.router.add_route(
"POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin
)
self.router.add_route(
"POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock
)
# WebSocket route # WebSocket route
self.router.add_view("/ws", ForumWebSocketView) self.router.add_view("/ws", ForumWebSocketView)
# Static HTML route # Static HTML route
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html) self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
def setup_notifications(self): def setup_notifications(self):
"""Set up notification listeners for WebSocket broadcasting""" """Set up notification listeners for WebSocket broadcasting"""
# Forum notifications # Forum notifications
self.services.forum.add_notification_listener("forum_created", self.on_forum_event) self.services.forum.add_notification_listener(
"forum_created", self.on_forum_event
)
# Thread notifications # Thread notifications
self.services.thread.add_notification_listener("thread_created", self.on_thread_event) self.services.thread.add_notification_listener(
"thread_created", self.on_thread_event
)
# Post notifications # Post notifications
self.services.post.add_notification_listener("post_created", self.on_post_event) self.services.post.add_notification_listener("post_created", self.on_post_event)
self.services.post.add_notification_listener("post_edited", self.on_post_event) self.services.post.add_notification_listener("post_edited", self.on_post_event)
self.services.post.add_notification_listener("post_deleted", self.on_post_event) self.services.post.add_notification_listener("post_deleted", self.on_post_event)
# Like notifications # Like notifications
self.services.post_like.add_notification_listener("post_liked", self.on_like_event) self.services.post_like.add_notification_listener(
self.services.post_like.add_notification_listener("post_unliked", self.on_like_event) "post_liked", self.on_like_event
)
self.services.post_like.add_notification_listener(
"post_unliked", self.on_like_event
)
async def on_forum_event(self, event_type, data): async def on_forum_event(self, event_type, data):
"""Handle forum events""" """Handle forum events"""
await ForumWebSocketView.broadcast_update(self, event_type, data) await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_thread_event(self, event_type, data): async def on_thread_event(self, event_type, data):
"""Handle thread events""" """Handle thread events"""
await ForumWebSocketView.broadcast_update(self, event_type, data) await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_post_event(self, event_type, data): async def on_post_event(self, event_type, data):
"""Handle post events""" """Handle post events"""
await ForumWebSocketView.broadcast_update(self, event_type, data) await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_like_event(self, event_type, data): async def on_like_event(self, event_type, data):
"""Handle like events""" """Handle like events"""
await ForumWebSocketView.broadcast_update(self, event_type, data) await ForumWebSocketView.broadcast_update(self, event_type, data)
async def serve_forum_html(self, request): async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component""" """Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request) return await self.parent.render_template("forum.html", request)
# return aiohttp.web.Response(text=html, content_type="text/html")
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app # Integration with main app

View File

@ -7,12 +7,12 @@ from snek.mapper.channel_message import ChannelMessageMapper
from snek.mapper.container import ContainerMapper from snek.mapper.container import ContainerMapper
from snek.mapper.drive import DriveMapper from snek.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper from snek.mapper.drive_item import DriveItemMapper
from snek.mapper.forum import ForumMapper, PostLikeMapper, PostMapper, ThreadMapper
from snek.mapper.notification import NotificationMapper from snek.mapper.notification import NotificationMapper
from snek.mapper.push import PushMapper from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object from snek.system.object import Object
@ -42,4 +42,3 @@ def get_mappers(app=None):
def get_mapper(name, app=None): def get_mapper(name, app=None):
return get_mappers(app=app)[name] return get_mappers(app=app)[name]

View File

@ -1,5 +1,5 @@
# mapper/forum.py # mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel from snek.model.forum import ForumModel, PostLikeModel, PostModel, ThreadModel
from snek.system.mapper import BaseMapper from snek.system.mapper import BaseMapper

View File

@ -9,12 +9,12 @@ from snek.model.channel_message import ChannelMessageModel
from snek.model.container import Container from snek.model.container import Container
from snek.model.drive import DriveModel from snek.model.drive import DriveModel
from snek.model.drive_item import DriveItemModel from snek.model.drive_item import DriveItemModel
from snek.model.forum import ForumModel, PostLikeModel, PostModel, ThreadModel
from snek.model.notification import NotificationModel from snek.model.notification import NotificationModel
from snek.model.push_registration import PushRegistrationModel from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel from snek.model.repository import RepositoryModel
from snek.model.user import UserModel from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel from snek.model.user_property import UserPropertyModel
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.object import Object from snek.system.object import Object
@ -44,5 +44,3 @@ def get_models():
def get_model(name): def get_model(name):
return get_models()[name] return get_models()[name]

View File

@ -12,10 +12,10 @@ class ChannelModel(BaseModel):
index = ModelField(name="index", required=True, kind=int, value=1000) index = ModelField(name="index", required=True, kind=int, value=1000)
last_message_on = ModelField(name="last_message_on", required=False, kind=str) last_message_on = ModelField(name="last_message_on", required=False, kind=str)
history_start = ModelField(name="history_start", required=False, kind=str) history_start = ModelField(name="history_start", required=False, kind=str)
@property @property
def is_dm(self): def is_dm(self):
return 'dm' in self['tag'].lower() return "dm" in self["tag"].lower()
async def get_last_message(self) -> ChannelMessageModel: async def get_last_message(self) -> ChannelMessageModel:
history_start_filter = "" history_start_filter = ""
@ -23,7 +23,9 @@ class ChannelModel(BaseModel):
history_start_filter = f" AND created_at > '{self['history_start']}' " history_start_filter = f" AND created_at > '{self['history_start']}' "
try: try:
async for model in self.app.services.channel_message.query( async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid"
+ history_start_filter
+ " ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):

View File

@ -4,9 +4,16 @@ from snek.system.model import BaseModel, ModelField
class ForumModel(BaseModel): class ForumModel(BaseModel):
"""Forum categories""" """Forum categories"""
name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
description = ModelField(name="description", required=False, kind=str, max_length=500) name = ModelField(
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True) name="name", required=True, kind=str, min_length=3, max_length=100
)
description = ModelField(
name="description", required=False, kind=str, max_length=500
)
slug = ModelField(
name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True
)
icon = ModelField(name="icon", required=False, kind=str) icon = ModelField(name="icon", required=False, kind=str)
position = ModelField(name="position", required=True, kind=int, value=0) position = ModelField(name="position", required=True, kind=int, value=0)
is_active = ModelField(name="is_active", required=True, kind=bool, value=True) is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
@ -18,12 +25,12 @@ class ForumModel(BaseModel):
async def get_threads(self, limit=50, offset=0): async def get_threads(self, limit=50, offset=0):
async for thread in self.app.services.thread.find( async for thread in self.app.services.thread.find(
forum_uid=self["uid"], forum_uid=self["uid"],
deleted_at=None, deleted_at=None,
_limit=limit, _limit=limit,
_offset=offset, _offset=offset,
order_by="-last_post_at" order_by="-last_post_at",
#order_by="is_pinned DESC, last_post_at DESC" # order_by="is_pinned DESC, last_post_at DESC"
): ):
yield thread yield thread
@ -39,8 +46,11 @@ class ForumModel(BaseModel):
# models/thread.py # models/thread.py
class ThreadModel(BaseModel): class ThreadModel(BaseModel):
"""Forum threads""" """Forum threads"""
forum_uid = ModelField(name="forum_uid", required=True, kind=str) forum_uid = ModelField(name="forum_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200) title = ModelField(
name="title", required=True, kind=str, min_length=5, max_length=200
)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$") slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str) created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False) is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
@ -56,7 +66,7 @@ class ThreadModel(BaseModel):
deleted_at=None, deleted_at=None,
_limit=limit, _limit=limit,
_offset=offset, _offset=offset,
order_by="created_at" order_by="created_at",
): ):
yield post yield post
@ -70,30 +80,35 @@ class ThreadModel(BaseModel):
await self.save() await self.save()
# models/post.py # models/post.py
class PostModel(BaseModel): class PostModel(BaseModel):
"""Forum posts""" """Forum posts"""
thread_uid = ModelField(name="thread_uid", required=True, kind=str) thread_uid = ModelField(name="thread_uid", required=True, kind=str)
forum_uid = ModelField(name="forum_uid", required=True, kind=str) forum_uid = ModelField(name="forum_uid", required=True, kind=str)
content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000) content = ModelField(
name="content", required=True, kind=str, min_length=1, max_length=10000
)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str) created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
edited_at = ModelField(name="edited_at", required=False, kind=str) edited_at = ModelField(name="edited_at", required=False, kind=str)
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str) edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False) is_first_post = ModelField(
name="is_first_post", required=True, kind=bool, value=False
)
like_count = ModelField(name="like_count", required=True, kind=int, value=0) like_count = ModelField(name="like_count", required=True, kind=int, value=0)
async def get_author(self): async def get_author(self):
return await self.app.services.user.get(uid=self["created_by_uid"]) return await self.app.services.user.get(uid=self["created_by_uid"])
async def is_liked_by(self, user_uid): async def is_liked_by(self, user_uid):
return await self.app.services.post_like.exists( return await self.app.services.post_like.exists(
post_uid=self["uid"], post_uid=self["uid"], user_uid=user_uid
user_uid=user_uid
) )
# models/post_like.py # models/post_like.py
class PostLikeModel(BaseModel): class PostLikeModel(BaseModel):
"""Post likes""" """Post likes"""
post_uid = ModelField(name="post_uid", required=True, kind=str) post_uid = ModelField(name="post_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str) user_uid = ModelField(name="user_uid", required=True, kind=str)

View File

@ -9,37 +9,43 @@ from snek.service.container import ContainerService
from snek.service.db import DBService from snek.service.db import DBService
from snek.service.drive import DriveService from snek.service.drive import DriveService
from snek.service.drive_item import DriveItemService from snek.service.drive_item import DriveItemService
from snek.service.forum import ForumService, PostLikeService, PostService, ThreadService
from snek.service.notification import NotificationService from snek.service.notification import NotificationService
from snek.service.push import PushService from snek.service.push import PushService
from snek.service.repository import RepositoryService from snek.service.repository import RepositoryService
from snek.service.socket import SocketService from snek.service.socket import SocketService
from snek.service.statistics import StatisticsService
from snek.service.user import UserService from snek.service.user import UserService
from snek.service.user_property import UserPropertyService from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService from snek.service.util import UtilService
from snek.system.object import Object from snek.system.object import Object
from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
_service_registry = {} _service_registry = {}
def register_service(name, service_cls): def register_service(name, service_cls):
_service_registry[name] = service_cls _service_registry[name] = service_cls
register = register_service register = register_service
@functools.cache @functools.cache
def get_services(app): def get_services(app):
result = Object( result = Object(
**{ **{
name: service_cls(app=app) name: service_cls(app=app)
for name, service_cls in _service_registry.items() for name, service_cls in _service_registry.items()
} }
) )
result.register = register_service result.register = register_service
return result return result
def get_service(name, app=None): def get_service(name, app=None):
return get_services(app=app)[name] return get_services(app=app)[name]
# Registering all services # Registering all services
register_service("user", UserService) register_service("user", UserService)
register_service("channel_member", ChannelMemberService) register_service("channel_member", ChannelMemberService)
@ -62,4 +68,3 @@ register_service("forum", ForumService)
register_service("thread", ThreadService) register_service("thread", ThreadService)
register_service("post", PostService) register_service("post", PostService)
register_service("post_like", PostLikeService) register_service("post_like", PostLikeService)

View File

@ -115,10 +115,9 @@ class ChannelService(BaseService):
async def clear(self, channel_uid): async def clear(self, channel_uid):
model = await self.get(uid=channel_uid) model = await self.get(uid=channel_uid)
model['history_from'] = datetime.now() model["history_from"] = datetime.now()
await self.save(model) await self.save(model)
async def ensure_public_channel(self, created_by_uid): async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public") model = await self.get(is_listed=True, tag="public")
is_moderator = False is_moderator = False

View File

@ -1,6 +1,8 @@
import time
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import sanitize_html from snek.system.template import sanitize_html
import time
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
mapper_name = "channel_message" mapper_name = "channel_message"
@ -10,7 +12,6 @@ class ChannelMessageService(BaseService):
self._configured_indexes = False self._configured_indexes = False
async def maintenance(self): async def maintenance(self):
args = {}
for message in self.mapper.db["channel_message"].find(): for message in self.mapper.db["channel_message"].find():
print(message) print(message)
try: try:
@ -34,7 +35,6 @@ class ChannelMessageService(BaseService):
time.sleep(0.1) time.sleep(0.1)
print(ex, flush=True) print(ex, flush=True)
while True: while True:
changed = 0 changed = 0
async for message in self.find(is_final=False): async for message in self.find(is_final=False):
@ -72,7 +72,7 @@ class ChannelMessageService(BaseService):
try: try:
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = template.render(**context)
model['html'] = sanitize_html(model['html']) model["html"] = sanitize_html(model["html"])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@ -99,9 +99,9 @@ class ChannelMessageService(BaseService):
if not user: if not user:
return {} return {}
#if not message["html"].startswith("<chat-message"): # if not message["html"].startswith("<chat-message"):
#message = await self.get(uid=message["uid"]) # message = await self.get(uid=message["uid"])
#await self.save(message) # await self.save(message)
return { return {
"uid": message["uid"], "uid": message["uid"],
@ -129,7 +129,7 @@ class ChannelMessageService(BaseService):
) )
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = template.render(**context)
model['html'] = sanitize_html(model['html']) model["html"] = sanitize_html(model["html"])
return await super().save(model) return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):

View File

@ -37,7 +37,7 @@ class ChatService(BaseService):
if not channel: if not channel:
raise Exception("Channel not found.") raise Exception("Channel not found.")
channel_message = await self.services.channel_message.get( channel_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False channel_uid=channel_uid, user_uid=user_uid, is_final=False
) )
if channel_message: if channel_message:
channel_message["message"] = message channel_message["message"] = message

View File

@ -9,16 +9,18 @@ class ContainerService(BaseService):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.compose_path = "snek-container-compose.yml" self.compose_path = "snek-container-compose.yml"
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler) self.compose = ComposeFileManager(
self.compose_path, self.container_event_handler
)
self.event_listeners = {} self.event_listeners = {}
async def shutdown(self): async def shutdown(self):
return await self.compose.shutdown() return await self.compose.shutdown()
async def add_event_listener(self, name, event,event_handler): async def add_event_listener(self, name, event, event_handler):
if not name in self.event_listeners: if name not in self.event_listeners:
self.event_listeners[name] = {} self.event_listeners[name] = {}
if not event in self.event_listeners[name]: if event not in self.event_listeners[name]:
self.event_listeners[name][event] = [] self.event_listeners[name][event] = []
self.event_listeners[name][event].append(event_handler) self.event_listeners[name][event].append(event_handler)
@ -27,7 +29,7 @@ class ContainerService(BaseService):
try: try:
self.event_listeners[name][event].remove(event_handler) self.event_listeners[name][event].remove(event_handler)
except ValueError: except ValueError:
pass pass
async def container_event_handler(self, name, event, data): async def container_event_handler(self, name, event, data):
event_listeners = self.event_listeners.get(name, {}) event_listeners = self.event_listeners.get(name, {})
@ -44,8 +46,10 @@ class ContainerService(BaseService):
return channel_uid return channel_uid
return f"channel-{channel_uid}" return f"channel-{channel_uid}"
async def get(self,channel_uid): async def get(self, channel_uid):
return await self.compose.get_instance(await self.get_container_name(channel_uid)) return await self.compose.get_instance(
await self.get_container_name(channel_uid)
)
async def stop(self, channel_uid): async def stop(self, channel_uid):
return await self.compose.stop(await self.get_container_name(channel_uid)) return await self.compose.stop(await self.get_container_name(channel_uid))
@ -60,12 +64,15 @@ class ContainerService(BaseService):
result = await self.create(channel_uid=channel["uid"]) result = await self.create(channel_uid=channel["uid"])
print(result) print(result)
async def get_status(self, channel_uid): async def get_status(self, channel_uid):
return await self.compose.get_instance_status(await self.get_container_name(channel_uid)) return await self.compose.get_instance_status(
await self.get_container_name(channel_uid)
)
async def write_stdin(self, channel_uid, data): async def write_stdin(self, channel_uid, data):
return await self.compose.write_stdin(await self.get_container_name(channel_uid), data) return await self.compose.write_stdin(
await self.get_container_name(channel_uid), data
)
async def create( async def create(
self, self,
@ -78,7 +85,7 @@ class ContainerService(BaseService):
volumes=None, volumes=None,
): ):
name = await self.get_container_name(channel_uid) name = await self.get_container_name(channel_uid)
test = await self.compose.get_instance(name) test = await self.compose.get_instance(name)
if test: if test:
return test return test
@ -116,4 +123,3 @@ class ContainerService(BaseService):
if await super().save(model): if await super().save(model):
return model return model
raise Exception(f"Failed to create container: {model.errors}") raise Exception(f"Failed to create container: {model.errors}")

View File

@ -1,18 +1,25 @@
# services/forum.py # services/forum.py
from snek.system.service import BaseService
import re import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List from collections.abc import Awaitable
from typing import Any, Callable, Dict, List
from snek.system.model import now from snek.system.model import now
from snek.system.service import BaseService
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None] EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService): class BaseForumService(BaseService):
""" """
Base mix-in that gives a service `add_notification_listener`, Base mix-in that gives a service `add_notification_listener`,
an internal `_dispatch_event` helper, and a public `notify` method. an internal `_dispatch_event` helper, and a public `notify` method.
""" """
def get_timestamp(self): def get_timestamp(self):
return now() return now()
def generate_uid(self): def generate_uid(self):
return str(uuid.uuid4()) return str(uuid.uuid4())
@ -64,15 +71,17 @@ class BaseForumService(BaseService):
class ForumService(BaseForumService): class ForumService(BaseForumService):
mapper_name = "forum" mapper_name = "forum"
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None): async def create_forum(
self, name, description, created_by_uid, slug=None, icon=None
):
if not slug: if not slug:
slug = self.generate_slug(name) slug = self.generate_slug(name)
# Check if slug exists # Check if slug exists
existing = await self.get(slug=slug) existing = await self.get(slug=slug)
if existing: if existing:
slug = f"{slug}-{self.generate_uid()[:8]}" slug = f"{slug}-{self.generate_uid()[:8]}"
model = await self.new() model = await self.new()
model["name"] = name model["name"] = name
model["description"] = description model["description"] = description
@ -80,7 +89,7 @@ class ForumService(BaseForumService):
model["created_by_uid"] = created_by_uid model["created_by_uid"] = created_by_uid
if icon: if icon:
model["icon"] = icon model["icon"] = icon
if await self.save(model): if await self.save(model):
await self.notify("forum_created", model) await self.notify("forum_created", model)
return model return model
@ -89,8 +98,8 @@ class ForumService(BaseForumService):
def generate_slug(self, text): def generate_slug(self, text):
# Convert to lowercase and replace spaces with hyphens # Convert to lowercase and replace spaces with hyphens
slug = text.lower().strip() slug = text.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug) slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r'[-\s]+', '-', slug) slug = re.sub(r"[-\s]+", "-", slug)
return slug return slug
async def get_active_forums(self): async def get_active_forums(self):
@ -105,21 +114,19 @@ class ForumService(BaseForumService):
await self.save(forum) await self.save(forum)
# services/thread.py # services/thread.py
class ThreadService(BaseForumService): class ThreadService(BaseForumService):
mapper_name = "thread" mapper_name = "thread"
async def create_thread(self, forum_uid, title, content, created_by_uid): async def create_thread(self, forum_uid, title, content, created_by_uid):
# Generate slug # Generate slug
slug = self.services.forum.generate_slug(title) slug = self.services.forum.generate_slug(title)
# Check if slug exists in this forum # Check if slug exists in this forum
existing = await self.get(forum_uid=forum_uid, slug=slug) existing = await self.get(forum_uid=forum_uid, slug=slug)
if existing: if existing:
slug = f"{slug}-{self.generate_uid()[:8]}" slug = f"{slug}-{self.generate_uid()[:8]}"
# Create thread # Create thread
thread = await self.new() thread = await self.new()
thread["forum_uid"] = forum_uid thread["forum_uid"] = forum_uid
@ -128,7 +135,7 @@ class ThreadService(BaseForumService):
thread["created_by_uid"] = created_by_uid thread["created_by_uid"] = created_by_uid
thread["last_post_at"] = self.get_timestamp() thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid thread["last_post_by_uid"] = created_by_uid
if await self.save(thread): if await self.save(thread):
# Create first post # Create first post
post = await self.services.post.create_post( post = await self.services.post.create_post(
@ -136,19 +143,18 @@ class ThreadService(BaseForumService):
forum_uid=forum_uid, forum_uid=forum_uid,
content=content, content=content,
created_by_uid=created_by_uid, created_by_uid=created_by_uid,
is_first_post=True is_first_post=True,
) )
# Update forum counters # Update forum counters
forum = await self.services.forum.get(uid=forum_uid) forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_thread_count() await forum.increment_thread_count()
await self.services.forum.update_last_post(forum_uid, thread["uid"]) await self.services.forum.update_last_post(forum_uid, thread["uid"])
await self.notify("thread_created", { await self.notify(
"thread": thread, "thread_created", {"thread": thread, "forum_uid": forum_uid}
"forum_uid": forum_uid )
})
return thread, post return thread, post
raise Exception(f"Failed to create thread: {thread.errors}") raise Exception(f"Failed to create thread: {thread.errors}")
@ -156,12 +162,12 @@ class ThreadService(BaseForumService):
thread = await self.get(uid=thread_uid) thread = await self.get(uid=thread_uid)
if not thread: if not thread:
return None return None
# Check if user is admin # Check if user is admin
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"): if not user.get("is_admin"):
return None return None
thread["is_pinned"] = not thread["is_pinned"] thread["is_pinned"] = not thread["is_pinned"]
await self.save(thread) await self.save(thread)
return thread return thread
@ -170,12 +176,12 @@ class ThreadService(BaseForumService):
thread = await self.get(uid=thread_uid) thread = await self.get(uid=thread_uid)
if not thread: if not thread:
return None return None
# Check if user is admin or thread creator # Check if user is admin or thread creator
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin") and thread["created_by_uid"] != user_uid: if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
return None return None
thread["is_locked"] = not thread["is_locked"] thread["is_locked"] = not thread["is_locked"]
await self.save(thread) await self.save(thread)
return thread return thread
@ -185,19 +191,21 @@ class ThreadService(BaseForumService):
class PostService(BaseForumService): class PostService(BaseForumService):
mapper_name = "post" mapper_name = "post"
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False): async def create_post(
self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False
):
# Check if thread is locked # Check if thread is locked
thread = await self.services.thread.get(uid=thread_uid) thread = await self.services.thread.get(uid=thread_uid)
if thread["is_locked"] and not is_first_post: if thread["is_locked"] and not is_first_post:
raise Exception("Thread is locked") raise Exception("Thread is locked")
post = await self.new() post = await self.new()
post["thread_uid"] = thread_uid post["thread_uid"] = thread_uid
post["forum_uid"] = forum_uid post["forum_uid"] = forum_uid
post["content"] = content post["content"] = content
post["created_by_uid"] = created_by_uid post["created_by_uid"] = created_by_uid
post["is_first_post"] = is_first_post post["is_first_post"] = is_first_post
if await self.save(post): if await self.save(post):
# Update thread counters # Update thread counters
if not is_first_post: if not is_first_post:
@ -205,18 +213,17 @@ class PostService(BaseForumService):
thread["last_post_at"] = self.get_timestamp() thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid thread["last_post_by_uid"] = created_by_uid
await self.services.thread.save(thread) await self.services.thread.save(thread)
# Update forum counters # Update forum counters
forum = await self.services.forum.get(uid=forum_uid) forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_post_count() await forum.increment_post_count()
await self.services.forum.update_last_post(forum_uid, thread_uid) await self.services.forum.update_last_post(forum_uid, thread_uid)
await self.notify("post_created", { await self.notify(
"post": post, "post_created",
"thread_uid": thread_uid, {"post": post, "thread_uid": thread_uid, "forum_uid": forum_uid},
"forum_uid": forum_uid )
})
return post return post
raise Exception(f"Failed to create post: {post.errors}") raise Exception(f"Failed to create post: {post.errors}")
@ -224,16 +231,16 @@ class PostService(BaseForumService):
post = await self.get(uid=post_uid) post = await self.get(uid=post_uid)
if not post: if not post:
return None return None
# Check permissions # Check permissions
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"): if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return None return None
post["content"] = content post["content"] = content
post["edited_at"] = self.get_timestamp() post["edited_at"] = self.get_timestamp()
post["edited_by_uid"] = user_uid post["edited_by_uid"] = user_uid
if await self.save(post): if await self.save(post):
await self.notify("post_edited", post) await self.notify("post_edited", post)
return post return post
@ -243,16 +250,16 @@ class PostService(BaseForumService):
post = await self.get(uid=post_uid) post = await self.get(uid=post_uid)
if not post: if not post:
return False return False
# Check permissions # Check permissions
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"): if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return False return False
# Don't allow deleting first post # Don't allow deleting first post
if post["is_first_post"]: if post["is_first_post"]:
return False return False
post["deleted_at"] = self.get_timestamp() post["deleted_at"] = self.get_timestamp()
if await self.save(post): if await self.save(post):
await self.notify("post_deleted", post) await self.notify("post_deleted", post)
@ -267,38 +274,36 @@ class PostLikeService(BaseForumService):
async def toggle_like(self, post_uid, user_uid): async def toggle_like(self, post_uid, user_uid):
# Check if already liked # Check if already liked
existing = await self.get(post_uid=post_uid, user_uid=user_uid) existing = await self.get(post_uid=post_uid, user_uid=user_uid)
if existing: if existing:
# Unlike # Unlike
await self.delete(uid=existing["uid"]) await self.delete(uid=existing["uid"])
# Update post like count # Update post like count
post = await self.services.post.get(uid=post_uid) post = await self.services.post.get(uid=post_uid)
post["like_count"] = max(0, post["like_count"] - 1) post["like_count"] = max(0, post["like_count"] - 1)
await self.services.post.save(post) await self.services.post.save(post)
await self.notify("post_unliked", { await self.notify(
"post_uid": post_uid, "post_unliked", {"post_uid": post_uid, "user_uid": user_uid}
"user_uid": user_uid )
})
return False return False
else: else:
# Like # Like
like = await self.new() like = await self.new()
like["post_uid"] = post_uid like["post_uid"] = post_uid
like["user_uid"] = user_uid like["user_uid"] = user_uid
if await self.save(like): if await self.save(like):
# Update post like count # Update post like count
post = await self.services.post.get(uid=post_uid) post = await self.services.post.get(uid=post_uid)
post["like_count"] += 1 post["like_count"] += 1
await self.services.post.save(post) await self.services.post.save(post)
await self.notify("post_liked", { await self.notify(
"post_uid": post_uid, "post_liked", {"post_uid": post_uid, "user_uid": user_uid}
"user_uid": user_uid )
})
return True return True
return None return None

View File

@ -1,8 +1,10 @@
from snek.system.service import BaseService import sqlite3
import sqlite3
from snek.system.service import BaseService
class StatisticsService(BaseService): class StatisticsService(BaseService):
def database(self): def database(self):
db_path = self.app.db_path.split("///")[-1] db_path = self.app.db_path.split("///")[-1]
print(db_path) print(db_path)
@ -15,9 +17,20 @@ class StatisticsService(BaseService):
return cursor.fetchall() return cursor.fetchall()
tables = [ tables = [
'http_access', 'user', 'channel', 'channel_member', 'broadcast', "http_access",
'channel_message', 'notification', 'repository', 'test', 'drive', "user",
'user_property', 'a', 'channel_attachment', 'push_registration' "channel",
"channel_member",
"broadcast",
"channel_message",
"notification",
"repository",
"test",
"drive",
"user_property",
"a",
"channel_attachment",
"push_registration",
] ]
for table in tables: for table in tables:
@ -36,30 +49,50 @@ class StatisticsService(BaseService):
print(f"\nColumn: {name} ({col_type})") print(f"\nColumn: {name} ({col_type})")
print(f"Distinct values: {distinct_count}") print(f"Distinct values: {distinct_count}")
if 'INT' in col_type_upper or 'BIGINT' in col_type_upper or 'FLOAT' in col_type_upper: if (
cursor.execute(f"SELECT MIN('{name}'), MAX('{name}'), AVG('{name}') FROM {table} WHERE '{name}' IS NOT NULL") "INT" in col_type_upper
or "BIGINT" in col_type_upper
or "FLOAT" in col_type_upper
):
cursor.execute(
f"SELECT MIN('{name}'), MAX('{name}'), AVG('{name}') FROM {table} WHERE '{name}' IS NOT NULL"
)
min_val, max_val, avg_val = cursor.fetchone() min_val, max_val, avg_val = cursor.fetchone()
print(f"Min: {min_val}, Max: {max_val}, Avg: {avg_val}") print(f"Min: {min_val}, Max: {max_val}, Avg: {avg_val}")
elif 'TEXT' in col_type_upper and ('date' in name.lower() or 'time' in name.lower() or 'created' in name.lower() or 'updated' in name.lower() or 'on' in name.lower()): elif "TEXT" in col_type_upper and (
cursor.execute(f"SELECT MIN({name}), MAX({name}) FROM {table} WHERE {name} IS NOT NULL") "date" in name.lower()
or "time" in name.lower()
or "created" in name.lower()
or "updated" in name.lower()
or "on" in name.lower()
):
cursor.execute(
f"SELECT MIN({name}), MAX({name}) FROM {table} WHERE {name} IS NOT NULL"
)
min_date, max_date = cursor.fetchone() min_date, max_date = cursor.fetchone()
print(f"Earliest: {min_date}, Latest: {max_date}") print(f"Earliest: {min_date}, Latest: {max_date}")
elif 'TEXT' in col_type_upper: elif "TEXT" in col_type_upper:
cursor.execute(f"SELECT LENGTH({name}) FROM {table} WHERE {name} IS NOT NULL") cursor.execute(
f"SELECT LENGTH({name}) FROM {table} WHERE {name} IS NOT NULL"
)
lengths = [len_row[0] for len_row in cursor.fetchall()] lengths = [len_row[0] for len_row in cursor.fetchall()]
if lengths: if lengths:
avg_length = sum(lengths) / len(lengths) avg_length = sum(lengths) / len(lengths)
max_length = max(lengths) max_length = max(lengths)
min_length = min(lengths) min_length = min(lengths)
print(f"Avg length: {avg_length:.2f}, Max length: {max_length}, Min length: {min_length}") print(
f"Avg length: {avg_length:.2f}, Max length: {max_length}, Min length: {min_length}"
)
else: else:
print("No data to compute length statistics.") print("No data to compute length statistics.")
# New statistics functions # New statistics functions
def get_time_series_stats(table_name, date_column): def get_time_series_stats(table_name, date_column):
cursor.execute(f"SELECT strftime('%Y-%m-%d', {date_column}) AS day, COUNT(*) FROM {table_name} GROUP BY day") cursor.execute(
f"SELECT strftime('%Y-%m-%d', {date_column}) AS day, COUNT(*) FROM {table_name} GROUP BY day"
)
return cursor.fetchall() return cursor.fetchall()
def get_count_created(table_name, date_column): def get_count_created(table_name, date_column):
@ -67,17 +100,21 @@ class StatisticsService(BaseService):
return cursor.fetchone()[0] return cursor.fetchone()[0]
def get_channels_per_user(): def get_channels_per_user():
cursor.execute("SELECT user_uid, COUNT(*) AS channel_count FROM channel_member GROUP BY user_uid ORDER BY channel_count DESC") cursor.execute(
"SELECT user_uid, COUNT(*) AS channel_count FROM channel_member GROUP BY user_uid ORDER BY channel_count DESC"
)
return cursor.fetchall() return cursor.fetchall()
def get_online_users(): def get_online_users():
cursor.execute("SELECT COUNT(*) FROM user WHERE last_ping >= datetime('now', '-5 minutes')") cursor.execute(
"SELECT COUNT(*) FROM user WHERE last_ping >= datetime('now', '-5 minutes')"
)
return cursor.fetchone()[0] return cursor.fetchone()[0]
# Example usage of new functions # Example usage of new functions
messages_per_day = get_time_series_stats('channel_message', 'created_at') messages_per_day = get_time_series_stats("channel_message", "created_at")
users_created = get_count_created('user', 'created_at') users_created = get_count_created("user", "created_at")
channels_created = get_count_created('channel', 'created_at') channels_created = get_count_created("channel", "created_at")
channels_per_user = get_channels_per_user() channels_per_user = get_channels_per_user()
online_users = get_online_users() online_users = get_online_users()

View File

@ -8,7 +8,7 @@ class UserPropertyService(BaseService):
async def set(self, user_uid, name, value): async def set(self, user_uid, name, value):
self.mapper.db.upsert( self.mapper.db.upsert(
"user_property", "user_property",
{ {
"user_uid": user_uid, "user_uid": user_uid,
"name": name, "name": name,

View File

@ -1,19 +1,16 @@
from snek.app import Application
from IPython import start_ipython from IPython import start_ipython
from snek.app import Application
class Shell: class Shell:
def __init__(self,db_path): def __init__(self, db_path):
self.app = Application(db_path=f"sqlite:///{db_path}") self.app = Application(db_path=f"sqlite:///{db_path}")
async def maintenance(self): async def maintenance(self):
await self.app.services.container.maintenance() await self.app.services.container.maintenance()
await self.app.services.channel_message.maintenance() await self.app.services.channel_message.maintenance()
def run(self): def run(self):
ns = { ns = {"app": self.app, "maintenance": self.maintenance}
"app": self.app,
"maintenance": self.maintenance
}
start_ipython(argv=[], user_ns=ns) start_ipython(argv=[], user_ns=ns)

View File

@ -1,28 +1,27 @@
import re import asyncio
import atexit
import json import json
from uuid import uuid4 import os
import re
import socket
import unittest
from collections.abc import AsyncGenerator, Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import ( from typing import (
Any, Any,
Dict, Dict,
Iterable,
List, List,
Optional, Optional,
AsyncGenerator,
Union,
Tuple,
Set, Set,
Tuple,
Union,
) )
from pathlib import Path from uuid import uuid4
import aiosqlite
import unittest
from types import SimpleNamespace
import asyncio
import aiohttp import aiohttp
import aiosqlite
from aiohttp import web from aiohttp import web
import socket
import os
import atexit
class AsyncDataSet: class AsyncDataSet:
@ -103,7 +102,7 @@ class AsyncDataSet:
# No socket file, become server # No socket file, become server
self._is_server = True self._is_server = True
await self._setup_server() await self._setup_server()
except Exception as e: except Exception:
# Fallback to client mode # Fallback to client mode
self._is_server = False self._is_server = False
await self._setup_client() await self._setup_client()
@ -213,7 +212,7 @@ class AsyncDataSet:
data = await request.json() data = await request.json()
try: try:
try: try:
_limit = data.pop("_limit",60) _limit = data.pop("_limit", 60)
except: except:
pass pass
@ -751,7 +750,7 @@ class AsyncDataSet:
for k, v in where.items() for k, v in where.items()
] ]
) )
return " WHERE " + " AND ".join(clauses), list(v for v in vals if v is not None) return " WHERE " + " AND ".join(clauses), [v for v in vals if v is not None]
async def _server_insert( async def _server_insert(
self, table: str, args: Dict[str, Any], return_id: bool = False self, table: str, args: Dict[str, Any], return_id: bool = False
@ -887,7 +886,6 @@ class AsyncDataSet:
except: except:
pass pass
where_clause, where_params = self._build_where(where) where_clause, where_params = self._build_where(where)
order_clause = f" ORDER BY {order_by}" if order_by else "" order_clause = f" ORDER BY {order_by}" if order_by else ""
extra = (f" LIMIT {_limit}" if _limit else "") + ( extra = (f" LIMIT {_limit}" if _limit else "") + (
@ -1209,4 +1207,3 @@ class TestAsyncDataSet(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -1,8 +1,9 @@
import copy
import json
import yaml
import asyncio import asyncio
import subprocess import copy
import json
import yaml
try: try:
import pty import pty
except Exception as ex: except Exception as ex:
@ -10,26 +11,28 @@ except Exception as ex:
print(ex) print(ex)
import os import os
class ComposeFileManager: class ComposeFileManager:
def __init__(self, compose_path="docker-compose.yml",event_handler=None): def __init__(self, compose_path="docker-compose.yml", event_handler=None):
self.compose_path = compose_path self.compose_path = compose_path
self._load() self._load()
self.running_instances = {} self.running_instances = {}
self.event_handler = event_handler self.event_handler = event_handler
async def shutdown(self): async def shutdown(self):
print("Stopping all sessions") print("Stopping all sessions")
tasks = [] tasks = []
for name in self.list_instances(): for name in self.list_instances():
proc = self.running_instances.get(name) proc = self.running_instances.get(name)
if not proc: if not proc:
continue continue
if proc['proc'].returncode == None: if proc["proc"].returncode is None:
print("Stopping",name) print("Stopping", name)
tasks.append(asyncio.create_task(proc['proc'].stop())) tasks.append(asyncio.create_task(proc["proc"].stop()))
print("Stopped",name,"gracefully") print("Stopped", name, "gracefully")
return tasks return tasks
def _load(self): def _load(self):
try: try:
with open(self.compose_path) as f: with open(self.compose_path) as f:
@ -47,22 +50,21 @@ class ComposeFileManager:
async def _create_readers(self, container_name): async def _create_readers(self, container_name):
instance = await self.get_instance(container_name) instance = await self.get_instance(container_name)
if not instance: if not instance:
return False return False
proc = self.running_instances.get(container_name) proc = self.running_instances.get(container_name)
if not proc: if not proc:
return False return False
async def reader(event_handler,stream): async def reader(event_handler, stream):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
while True: while True:
line = await loop.run_in_executor(None,os.read,stream,1024) line = await loop.run_in_executor(None, os.read, stream, 1024)
if not line: if not line:
break break
await event_handler(container_name,"stdout",line) await event_handler(container_name, "stdout", line)
await self.stop(container_name) await self.stop(container_name)
asyncio.create_task(reader(self.event_handler,proc['master']))
asyncio.create_task(reader(self.event_handler, proc["master"]))
def create_instance( def create_instance(
self, self,
@ -79,7 +81,7 @@ class ComposeFileManager:
"context": ".", "context": ".",
"dockerfile": "DockerfileUbuntu", "dockerfile": "DockerfileUbuntu",
}, },
"user":"root", "user": "root",
"working_dir": "/home/retoor/projects/snek", "working_dir": "/home/retoor/projects/snek",
"environment": [f"SNEK_UID={name}"], "environment": [f"SNEK_UID={name}"],
} }
@ -109,9 +111,9 @@ class ComposeFileManager:
instance = self.compose.get("services", {}).get(name) instance = self.compose.get("services", {}).get(name)
if not instance: if not instance:
return None return None
instance = json.loads(json.dumps(instance,default=str)) instance = json.loads(json.dumps(instance, default=str))
instance['status'] = await self.get_instance_status(name) instance["status"] = await self.get_instance_status(name)
return instance return instance
def duplicate_instance(self, name, new_name): def duplicate_instance(self, name, new_name):
orig = self.get_instance(name) orig = self.get_instance(name)
@ -135,7 +137,14 @@ class ComposeFileManager:
if name not in self.list_instances(): if name not in self.list_instances():
return "error" return "error"
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "ps", "--services", "--filter", f"status=running", "docker",
"compose",
"-f",
self.compose_path,
"ps",
"--services",
"--filter",
f"status=running",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@ -148,25 +157,30 @@ class ComposeFileManager:
await self.event_handler(name, "stdin", data) await self.event_handler(name, "stdin", data)
proc = self.running_instances.get(name) proc = self.running_instances.get(name)
if not proc: if not proc:
return False return False
try: try:
os.write(proc['master'], data.encode()) os.write(proc["master"], data.encode())
return True return True
except Exception as ex: except Exception as ex:
print(ex) print(ex)
await self.stop(name) await self.stop(name)
return False return False
async def stop(self, name): async def stop(self, name):
"""Asynchronously stop a container by doing 'docker compose stop [name]'.""" """Asynchronously stop a container by doing 'docker compose stop [name]'."""
if name not in self.list_instances(): if name not in self.list_instances():
return False return False
status = await self.get_instance_status(name) status = await self.get_instance_status(name)
if status != "running": if status != "running":
return True return True
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "stop", name, "docker",
"compose",
"-f",
self.compose_path,
"stop",
name,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@ -177,30 +191,41 @@ class ComposeFileManager:
try: try:
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
except Exception as ex: except Exception as ex:
stdout = b'' stdout = b""
stderr = str(ex).encode() stderr = str(ex).encode()
await self.event_handler(name,"stdout",stdout or stderr) await self.event_handler(name, "stdout", stdout or stderr)
print("Return code", proc.returncode) print("Return code", proc.returncode)
if stdout: if stdout:
await self.event_handler(name,"stdout",stdout or b'') await self.event_handler(name, "stdout", stdout or b"")
return stdout and stdout.decode(errors="ignore") or "" return stdout and stdout.decode(errors="ignore") or ""
await self.event_handler(name,"stdout",stderr or b'') await self.event_handler(name, "stdout", stderr or b"")
return stderr and stderr.decode(errors="ignore") or "" return stderr and stderr.decode(errors="ignore") or ""
async def start(self, name): async def start(self, name):
"""Asynchronously start a container by doing 'docker compose up -d [name]'.""" """Asynchronously start a container by doing 'docker compose up -d [name]'."""
if name not in self.list_instances(): if name not in self.list_instances():
return False return False
status = await self.get_instance_status(name) status = await self.get_instance_status(name)
if name in self.running_instances and status == "running" and self.running_instances.get(name) and self.running_instances.get(name).get('proc').returncode == None: if (
return True name in self.running_instances
and status == "running"
and self.running_instances.get(name)
and self.running_instances.get(name).get("proc").returncode is None
):
return True
elif name in self.running_instances: elif name in self.running_instances:
del self.running_instances[name] del self.running_instances[name]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "up", name, "-d", "docker",
"compose",
"-f",
self.compose_path,
"up",
name,
"-d",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
@ -210,24 +235,29 @@ class ComposeFileManager:
try: try:
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
except Exception as ex: except Exception as ex:
stdout = b'' stdout = b""
stderr = str(ex).encode() stderr = str(ex).encode()
if stdout: if stdout:
print(stdout.decode(errors="ignore")) print(stdout.decode(errors="ignore"))
if stderr: if stderr:
print(stderr.decode(errors="ignore")) print(stderr.decode(errors="ignore"))
await self.event_handler(name,"stdout",stdout or stderr) await self.event_handler(name, "stdout", stdout or stderr)
print("Return code", proc.returncode) print("Return code", proc.returncode)
master, slave = pty.openpty() master, slave = pty.openpty()
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "exec", name, "/usr/local/bin/entry", "docker",
"compose",
"-f",
self.compose_path,
"exec",
name,
"/usr/local/bin/entry",
stdin=slave, stdin=slave,
stdout=slave, stdout=slave,
stderr=slave, stderr=slave,
) )
proc = {'proc': proc, 'master': master, 'slave': slave} proc = {"proc": proc, "master": master, "slave": slave}
self.running_instances[name] = proc self.running_instances[name] = proc
await self._create_readers(name) await self._create_readers(name)
return True return True

View File

@ -1,7 +1,7 @@
DEFAULT_LIMIT = 30 DEFAULT_LIMIT = 30
import asyncio import asyncio
import typing import typing
import traceback
from snek.system.model import BaseModel from snek.system.model import BaseModel
@ -11,7 +11,7 @@ class BaseMapper:
default_limit: int = DEFAULT_LIMIT default_limit: int = DEFAULT_LIMIT
table_name: str = None table_name: str = None
semaphore = asyncio.Semaphore(1) semaphore = asyncio.Semaphore(1)
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
@ -27,17 +27,16 @@ class BaseMapper:
async def run_in_executor(self, func, *args, **kwargs): async def run_in_executor(self, func, *args, **kwargs):
use_semaphore = kwargs.pop("use_semaphore", False) use_semaphore = kwargs.pop("use_semaphore", False)
def _execute(): def _execute():
result = func(*args, **kwargs) result = func(*args, **kwargs)
if use_semaphore: if use_semaphore:
self.db.commit() self.db.commit()
return result return result
return _execute()
#async with self.semaphore:
# return await self.loop.run_in_executor(None, _execute)
return _execute()
# async with self.semaphore:
# return await self.loop.run_in_executor(None, _execute)
async def new(self): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@ -51,7 +50,7 @@ class BaseMapper:
kwargs["uid"] = uid kwargs["uid"] = uid
if not kwargs.get("deleted_at"): if not kwargs.get("deleted_at"):
kwargs["deleted_at"] = None kwargs["deleted_at"] = None
#traceback.print_exc() # traceback.print_exc()
record = await self.db.get(self.table_name, kwargs) record = await self.db.get(self.table_name, kwargs)
if not record: if not record:
@ -65,20 +64,18 @@ class BaseMapper:
async def exists(self, **kwargs): async def exists(self, **kwargs):
return await self.db.count(self.table_name, kwargs) return await self.db.count(self.table_name, kwargs)
# return await self.run_in_executor(self.table.exists, **kwargs)
#return await self.run_in_executor(self.table.exists, **kwargs)
async def count(self, **kwargs) -> int: async def count(self, **kwargs) -> int:
return await self.db.count(self.table_name,kwargs) return await self.db.count(self.table_name, kwargs)
async def save(self, model: BaseModel) -> bool: async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"): if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.") raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update() model.updated_at.update()
await self.upsert(model) await self.upsert(model)
return model return model
#return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True) # return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
async def find(self, **kwargs) -> typing.AsyncGenerator: async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
@ -100,10 +97,12 @@ class BaseMapper:
yield dict(record) yield dict(record)
async def update(self, model): async def update(self, model):
if not model["deleted_at"] is None: if model["deleted_at"] is not None:
raise Exception("Can't update deleted record.") raise Exception("Can't update deleted record.")
model.updated_at.update() model.updated_at.update()
return await self.db.update(self.table_name, model.record, {"uid": model["uid"]}) return await self.db.update(
self.table_name, model.record, {"uid": model["uid"]}
)
async def upsert(self, model): async def upsert(self, model):
model.updated_at.update() model.updated_at.update()

View File

@ -2,7 +2,7 @@
import re import re
from types import SimpleNamespace from types import SimpleNamespace
from app.cache import time_cache_async, time_cache from app.cache import time_cache, time_cache_async
from mistune import HTMLRenderer, Markdown from mistune import HTMLRenderer, Markdown
from mistune.plugins.formatting import strikethrough from mistune.plugins.formatting import strikethrough
from mistune.plugins.spoiler import spoiler from mistune.plugins.spoiler import spoiler
@ -12,7 +12,6 @@ from pygments.formatters import html
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
def strip_markdown(md_text): def strip_markdown(md_text):
# Remove code blocks ( # Remove code blocks (
md_text = re.sub(r"[\s\S]?```", "", md_text) md_text = re.sub(r"[\s\S]?```", "", md_text)
@ -50,7 +49,7 @@ class MarkdownRenderer(HTMLRenderer):
return get_lexer_by_name(lang, stripall=True) return get_lexer_by_name(lang, stripall=True)
except: except:
return get_lexer_by_name(default, stripall=True) return get_lexer_by_name(default, stripall=True)
@time_cache(timeout=60 * 60) @time_cache(timeout=60 * 60)
def block_code(self, code, lang=None, info=None): def block_code(self, code, lang=None, info=None):
if not lang: if not lang:

View File

@ -3,31 +3,17 @@
# This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers. # This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers.
import secrets import secrets
from aiohttp import web from aiohttp import web
@web.middleware @web.middleware
async def csp_middleware(request, handler): async def csp_middleware(request, handler):
nonce = secrets.token_hex(16) nonce = secrets.token_hex(16)
origin = request.headers.get('Origin') request.headers.get("Origin")
csp_policy = ( request["csp_nonce"] = nonce
"default-src 'self'; "
f"script-src 'self' {origin} 'nonce-{nonce}'; "
f"style-src 'self' 'unsafe-inline' {origin} 'nonce-{nonce}'; "
"img-src *; "
"connect-src 'self' https://umami.molodetz.nl; "
"font-src *; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-src 'self'; "
"worker-src *; "
"media-src *; "
"manifest-src 'self';"
)
request['csp_nonce'] = nonce
response = await handler(request) response = await handler(request)
#response.headers['Content-Security-Policy'] = csp_policy # response.headers['Content-Security-Policy'] = csp_policy
return response return response
@ -37,6 +23,7 @@ async def no_cors_middleware(request, handler):
response.headers.pop("Access-Control-Allow-Origin", None) response.headers.pop("Access-Control-Allow-Origin", None)
return response return response
@web.middleware @web.middleware
async def cors_allow_middleware(request, handler): async def cors_allow_middleware(request, handler):
response = await handler(request) response = await handler(request)
@ -48,6 +35,7 @@ async def cors_allow_middleware(request, handler):
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
return response return response
@web.middleware @web.middleware
async def auth_middleware(request, handler): async def auth_middleware(request, handler):
request["user"] = None request["user"] = None
@ -57,6 +45,7 @@ async def auth_middleware(request, handler):
) )
return await handler(request) return await handler(request)
@web.middleware @web.middleware
async def cors_middleware(request, handler): async def cors_middleware(request, handler):
if request.headers.get("Allow"): if request.headers.get("Allow"):

View File

@ -1,5 +1,4 @@
from snek.mapper import get_mapper from snek.mapper import get_mapper
from snek.model.user import UserModel
from snek.system.mapper import BaseMapper from snek.system.mapper import BaseMapper
@ -40,7 +39,7 @@ class BaseService:
yield record yield record
async def get(self, *args, **kwargs): async def get(self, *args, **kwargs):
if not "deleted_at" in kwargs: if "deleted_at" not in kwargs:
kwargs["deleted_at"] = None kwargs["deleted_at"] = None
uid = kwargs.get("uid") uid = kwargs.get("uid")
if args: if args:
@ -50,7 +49,7 @@ class BaseService:
if result and result.__class__ == self.mapper.model_class: if result and result.__class__ == self.mapper.model_class:
return result return result
kwargs["uid"] = uid kwargs["uid"] = uid
print(kwargs,"ZZZZZZZ") print(kwargs, "ZZZZZZZ")
result = await self.mapper.get(**kwargs) result = await self.mapper.get(**kwargs)
if result: if result:
await self.cache.set(result["uid"], result) await self.cache.set(result["uid"], result)

View File

@ -1,34 +1,41 @@
import asyncio
from aiohttp import web, WSMsgType
from datetime import datetime, timedelta, timezone
from collections import defaultdict
import html import html
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from aiohttp import WSMsgType, web
def create_stats_structure(): def create_stats_structure():
"""Creates the nested dictionary structure for storing statistics.""" """Creates the nested dictionary structure for storing statistics."""
def nested_dd(): def nested_dd():
return defaultdict(lambda: defaultdict(int)) return defaultdict(lambda: defaultdict(int))
return defaultdict(nested_dd) return defaultdict(nested_dd)
def get_time_keys(dt: datetime): def get_time_keys(dt: datetime):
"""Generates dictionary keys for different time granularities.""" """Generates dictionary keys for different time granularities."""
return { return {
"hour": dt.strftime('%Y-%m-%d-%H'), "hour": dt.strftime("%Y-%m-%d-%H"),
"day": dt.strftime('%Y-%m-%d'), "day": dt.strftime("%Y-%m-%d"),
"week": dt.strftime('%Y-%W'), # Week number, Monday is first day "week": dt.strftime("%Y-%W"), # Week number, Monday is first day
"month": dt.strftime('%Y-%m'), "month": dt.strftime("%Y-%m"),
} }
def update_stats_counters(stats_dict: defaultdict, now: datetime): def update_stats_counters(stats_dict: defaultdict, now: datetime):
"""Increments the appropriate time-based counters in a stats dictionary.""" """Increments the appropriate time-based counters in a stats dictionary."""
keys = get_time_keys(now) keys = get_time_keys(now)
stats_dict['by_hour'][keys['hour']] += 1 stats_dict["by_hour"][keys["hour"]] += 1
stats_dict['by_day'][keys['day']] += 1 stats_dict["by_day"][keys["day"]] += 1
stats_dict['by_week'][keys['week']] += 1 stats_dict["by_week"][keys["week"]] += 1
stats_dict['by_month'][keys['month']] += 1 stats_dict["by_month"][keys["month"]] += 1
def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str:
def generate_time_series_svg(
title: str, data: list[tuple[str, int]], y_label: str
) -> str:
"""Generates a responsive SVG bar chart for time-series data.""" """Generates a responsive SVG bar chart for time-series data."""
if not data: if not data:
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>" return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
@ -36,14 +43,14 @@ def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: s
svg_height, svg_width = 250, 600 svg_height, svg_width = 250, 600
bar_padding = 5 bar_padding = 5
bar_width = (svg_width - 50) / len(data) - bar_padding bar_width = (svg_width - 50) / len(data) - bar_padding
bars = "" bars = ""
labels = "" labels = ""
for i, (key, val) in enumerate(data): for i, (key, val) in enumerate(data):
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0 bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
x = i * (bar_width + bar_padding) + 40 x = i * (bar_width + bar_padding) + 40
y = svg_height - bar_height - 30 y = svg_height - bar_height - 30
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>' bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>' labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
@ -61,25 +68,32 @@ def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: s
</div> </div>
""" """
@web.middleware @web.middleware
async def middleware(request, handler): async def middleware(request, handler):
"""Middleware to count all incoming HTTP requests.""" """Middleware to count all incoming HTTP requests."""
# Avoid counting requests to the stats page itself # Avoid counting requests to the stats page itself
if request.path.startswith('/stats.html'): if request.path.startswith("/stats.html"):
return await handler(request) return await handler(request)
update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc)) update_stats_counters(
request.app["stats"]["http_requests"], datetime.now(timezone.utc)
)
return await handler(request) return await handler(request)
def update_websocket_stats(app): def update_websocket_stats(app):
update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc)) update_stats_counters(
app["stats"]["websocket_requests"], datetime.now(timezone.utc)
)
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict): async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
"""This function proxies WebSocket messages AND counts them.""" """This function proxies WebSocket messages AND counts them."""
async for msg in ws_from: async for msg in ws_from:
# This is the key part for monitoring WebSockets # This is the key part for monitoring WebSockets
update_stats_counters(stats_dict, datetime.now(timezone.utc)) update_stats_counters(stats_dict, datetime.now(timezone.utc))
if msg.type == WSMsgType.TEXT: if msg.type == WSMsgType.TEXT:
await ws_to.send_str(msg.data) await ws_to.send_str(msg.data)
elif msg.type == WSMsgType.BINARY: elif msg.type == WSMsgType.BINARY:
@ -91,27 +105,27 @@ async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
async def stats_handler(request: web.Request): async def stats_handler(request: web.Request):
"""Handler to display the statistics dashboard.""" """Handler to display the statistics dashboard."""
stats = request.app['stats'] stats = request.app["stats"]
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Helper to prepare data for charts # Helper to prepare data for charts
def get_data(source, period, count): def get_data(source, period, count):
data = [] data = []
for i in range(count - 1, -1, -1): for i in range(count - 1, -1, -1):
if period == 'hour': if period == "hour":
dt = now - timedelta(hours=i) dt = now - timedelta(hours=i)
key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00') key, label = dt.strftime("%Y-%m-%d-%H"), dt.strftime("%H:00")
data.append((label, source['by_hour'].get(key, 0))) data.append((label, source["by_hour"].get(key, 0)))
elif period == 'day': elif period == "day":
dt = now - timedelta(days=i) dt = now - timedelta(days=i)
key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a') key, label = dt.strftime("%Y-%m-%d"), dt.strftime("%a")
data.append((label, source['by_day'].get(key, 0))) data.append((label, source["by_day"].get(key, 0)))
return data return data
http_hourly = get_data(stats['http_requests'], 'hour', 24) http_hourly = get_data(stats["http_requests"], "hour", 24)
ws_hourly = get_data(stats['ws_messages'], 'hour', 24) ws_hourly = get_data(stats["ws_messages"], "hour", 24)
http_daily = get_data(stats['http_requests'], 'day', 7) http_daily = get_data(stats["http_requests"], "day", 7)
ws_daily = get_data(stats['ws_messages'], 'day', 7) ws_daily = get_data(stats["ws_messages"], "day", 7)
body = f""" body = f"""
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head> <html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
@ -125,5 +139,4 @@ async def stats_handler(request: web.Request):
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")} {generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
</body></html> </body></html>
""" """
return web.Response(text=body, content_type='text/html') return web.Response(text=body, content_type="text/html")

View File

@ -81,27 +81,28 @@ emoji.EMOJI_DATA[
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"] ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
def sanitize_html(value): def sanitize_html(value):
soup = BeautifulSoup(value, 'html.parser') soup = BeautifulSoup(value, "html.parser")
for script in soup.find_all('script'): for script in soup.find_all("script"):
script.decompose() script.decompose()
#for iframe in soup.find_all('iframe'): # for iframe in soup.find_all('iframe'):
#iframe.decompose() # iframe.decompose()
for tag in soup.find_all(['object', 'embed']): for tag in soup.find_all(["object", "embed"]):
tag.decompose() tag.decompose()
for tag in soup.find_all(): for tag in soup.find_all():
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus'] event_attributes = ["onclick", "onerror", "onload", "onmouseover", "onfocus"]
for attr in event_attributes: for attr in event_attributes:
if attr in tag.attrs: if attr in tag.attrs:
del tag[attr] del tag[attr]
for img in soup.find_all('img'): for img in soup.find_all("img"):
if 'onerror' in img.attrs: if "onerror" in img.attrs:
img.decompose() img.decompose()
return soup.prettify() return soup.prettify()
@ -126,6 +127,7 @@ def set_link_target_blank(text):
return str(soup) return str(soup)
def whitelist_attributes(html): def whitelist_attributes(html):
return sanitize_html(html) return sanitize_html(html)

View File

@ -13,23 +13,27 @@ from snek.system.view import BaseView
register_heif_opener() register_heif_opener()
from snek.view.drive import DriveApiView from snek.view.drive import DriveApiView
class ChannelDriveApiView(DriveApiView): class ChannelDriveApiView(DriveApiView):
login_required = True login_required = True
async def get_target(self): async def get_target(self):
target = await self.services.channel.get_home_folder(self.request.match_info.get("channel_uid")) target = await self.services.channel.get_home_folder(
self.request.match_info.get("channel_uid")
)
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
return target return target
async def get_download_url(self, rel): async def get_download_url(self, rel):
return f"/channel/{self.request.match_info.get('channel_uid')}/drive/{urllib.parse.quote(rel)}" return f"/channel/{self.request.match_info.get('channel_uid')}/drive/{urllib.parse.quote(rel)}"
class ChannelAttachmentView(BaseView): class ChannelAttachmentView(BaseView):
login_required=False login_required = False
async def get(self): async def get(self):
relative_path = self.request.match_info.get("relative_url") relative_path = self.request.match_info.get("relative_url")
@ -114,7 +118,9 @@ class ChannelAttachmentView(BaseView):
response.headers["Content-Disposition"] = ( response.headers["Content-Disposition"] = (
f'attachment; filename="{channel_attachment["name"]}"' f'attachment; filename="{channel_attachment["name"]}"'
) )
response.headers["Content-Type"] = original_format or "application/octet-stream" response.headers["Content-Type"] = (
original_format or "application/octet-stream"
)
return response return response
async def post(self): async def post(self):
@ -164,11 +170,11 @@ class ChannelAttachmentView(BaseView):
class ChannelAttachmentUploadView(BaseView): class ChannelAttachmentUploadView(BaseView):
login_required = True login_required = True
async def get(self): async def get(self):
channel_uid = self.request.match_info.get("channel_uid") channel_uid = self.request.match_info.get("channel_uid")
user_uid = self.request.session.get("uid") user_uid = self.request.session.get("uid")
@ -184,37 +190,44 @@ class ChannelAttachmentUploadView(BaseView):
file = None file = None
filename = None filename = None
msg = await ws.receive() msg = await ws.receive()
if not msg.type == web.WSMsgType.TEXT: if not msg.type == web.WSMsgType.TEXT:
return web.HTTPBadRequest() return web.HTTPBadRequest()
data = msg.json() data = msg.json()
if not data.get('type') == 'start': if not data.get("type") == "start":
return web.HTTPBadRequest() return web.HTTPBadRequest()
filename = data['filename'] filename = data["filename"]
attachment = await self.services.channel_attachment.create_file( attachment = await self.services.channel_attachment.create_file(
channel_uid=channel_uid, name=filename, user_uid=user_uid channel_uid=channel_uid, name=filename, user_uid=user_uid
) )
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True) pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(attachment["path"], "wb") as file: async with aiofiles.open(attachment["path"], "wb") as file:
print("File openend.", filename) print("File openend.", filename)
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.BINARY: if msg.type == web.WSMsgType.BINARY:
print("Binary",filename) print("Binary", filename)
if file is not None: if file is not None:
await file.write(msg.data) await file.write(msg.data)
await ws.send_json({"type": "progress", "filename": filename, "bytes": await file.tell()}) await ws.send_json(
{
"type": "progress",
"filename": filename,
"bytes": await file.tell(),
}
)
elif msg.type == web.WSMsgType.TEXT: elif msg.type == web.WSMsgType.TEXT:
print("TExt",filename) print("TExt", filename)
print(msg.json()) print(msg.json())
data = msg.json() data = msg.json()
if data.get('type') == 'end': if data.get("type") == "end":
relative_url = urllib.parse.quote(attachment["relative_url"]) relative_url = urllib.parse.quote(attachment["relative_url"])
await ws.send_json({"type": "done", "file": relative_url, "filename": filename}) await ws.send_json(
{"type": "done", "file": relative_url, "filename": filename}
)
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
break break
return ws return ws

View File

@ -1,10 +1,13 @@
from snek.system.view import BaseView import functools
import functools
from aiohttp import web from aiohttp import web
from snek.system.view import BaseView
class ContainerView(BaseView): class ContainerView(BaseView):
login_required= True login_required = True
async def stdout_event_handler(self, ws, data): async def stdout_event_handler(self, ws, data):
try: try:
@ -12,8 +15,8 @@ class ContainerView(BaseView):
except Exception as ex: except Exception as ex:
print(ex) print(ex)
await ws.close() await ws.close()
return False return False
return True return True
async def create_stdout_event_handler(self, ws): async def create_stdout_event_handler(self, ws):
return functools.partial(self.stdout_event_handler, ws) return functools.partial(self.stdout_event_handler, ws)
@ -26,23 +29,27 @@ class ContainerView(BaseView):
return self.HTTPUnauthorized() return self.HTTPUnauthorized()
channel_uid = self.request.match_info.get("channel_uid") channel_uid = self.request.match_info.get("channel_uid")
channel_member = await self.services.channel_member.get(channel_uid=channel_uid, user_uid=self.request.session.get("uid")) channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.request.session.get("uid")
)
if not channel_member: if not channel_member:
return web.HTTPUnauthorized() return web.HTTPUnauthorized()
container = await self.services.container.get(channel_uid) container = await self.services.container.get(channel_uid)
if not container: if not container:
return web.HTTPNotFound() return web.HTTPNotFound()
if not container['status'] == 'running': if not container["status"] == "running":
resp = await self.services.container.start(channel_uid) await self.services.container.start(channel_uid)
await ws.send_bytes(b'Container is starting\n\n') await ws.send_bytes(b"Container is starting\n\n")
container_name = await self.services.container.get_container_name(channel_uid) container_name = await self.services.container.get_container_name(channel_uid)
event_handler = await self.create_stdout_event_handler(ws) event_handler = await self.create_stdout_event_handler(ws)
await self.services.container.add_event_listener(container_name, "stdout", event_handler) await self.services.container.add_event_listener(
container_name, "stdout", event_handler
)
while True: while True:
data = await ws.receive() data = await ws.receive()
@ -54,8 +61,8 @@ class ContainerView(BaseView):
elif data.type == web.WSMsgType.ERROR: elif data.type == web.WSMsgType.ERROR:
break break
await self.services.container.remove_event_listener(container_name, channel_uid, "stdout") await self.services.container.remove_event_listener(
container_name, channel_uid, "stdout"
return ws )
return ws

View File

@ -1,9 +1,6 @@
import mimetypes import mimetypes
import os
import urllib.parse import urllib.parse
from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote
from aiohttp import web from aiohttp import web
@ -42,7 +39,7 @@ class DriveApiView(BaseView):
async def get(self): async def get(self):
target = await self.get_target() target = await self.get_target()
original_target = target original_target = target
rel = self.request.query.get("path", "") rel = self.request.query.get("path", "")
offset = int(self.request.query.get("offset", 0)) offset = int(self.request.query.get("offset", 0))
limit = int(self.request.query.get("limit", 20)) limit = int(self.request.query.get("limit", 20))

View File

@ -1,19 +1,15 @@
# views/forum.py # views/forum.py
from snek.system.view import BaseView
from aiohttp import web
import json import json
from aiohttp import web from aiohttp import web
from snek.system.view import BaseView from snek.system.view import BaseView
class ForumIndexView(BaseView): class ForumIndexView(BaseView):
login_required = True login_required = True
async def get(self): async def get(self):
if self.login_required and not self.session.get("logged_in"): if self.login_required and not self.session.get("logged_in"):
return web.HTTPFound("/") return web.HTTPFound("/")
@ -71,407 +67,421 @@ class ForumIndexView(BaseView):
) )
class ForumView(BaseView): class ForumView(BaseView):
"""REST API endpoints for forum""" """REST API endpoints for forum"""
login_required = True login_required = True
async def get_forums(self): async def get_forums(self):
request = self request = self
self = request.app self = request.app
"""GET /forum/api/forums - Get all active forums""" """GET /forum/api/forums - Get all active forums"""
forums = [] forums = []
async for forum in self.services.forum.get_active_forums(): async for forum in self.services.forum.get_active_forums():
forums.append({ forums.append(
"uid": forum["uid"], {
"name": forum["name"], "uid": forum["uid"],
"description": forum["description"], "name": forum["name"],
"slug": forum["slug"], "description": forum["description"],
"icon": forum["icon"], "slug": forum["slug"],
"thread_count": forum["thread_count"], "icon": forum["icon"],
"post_count": forum["post_count"], "thread_count": forum["thread_count"],
"last_post_at": forum["last_post_at"], "post_count": forum["post_count"],
"last_thread_uid": forum["last_thread_uid"] "last_post_at": forum["last_post_at"],
}) "last_thread_uid": forum["last_thread_uid"],
}
)
return web.json_response({"forums": forums}) return web.json_response({"forums": forums})
async def get_forum(self): async def get_forum(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""GET /forum/api/forums/:slug - Get forum by slug""" """GET /forum/api/forums/:slug - Get forum by slug"""
slug = self.request.match_info["slug"] slug = self.request.match_info["slug"]
forum = await self.services.forum.get(slug=slug, is_active=True) forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum: if not forum:
return web.json_response({"error": "Forum not found"}, status=404) return web.json_response({"error": "Forum not found"}, status=404)
# Get threads # Get threads
threads = [] threads = []
page = int(self.request.query.get("page", 1)) page = int(self.request.query.get("page", 1))
limit = 50 limit = 50
offset = (page - 1) * limit offset = (page - 1) * limit
async for thread in forum.get_threads(limit=limit, offset=offset): async for thread in forum.get_threads(limit=limit, offset=offset):
# Get author info # Get author info
author = await self.services.user.get(uid=thread["created_by_uid"]) author = await self.services.user.get(uid=thread["created_by_uid"])
last_post_author = None last_post_author = None
if thread["last_post_by_uid"]: if thread["last_post_by_uid"]:
last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"]) last_post_author = await self.services.user.get(
uid=thread["last_post_by_uid"]
threads.append({ )
"uid": thread["uid"],
"title": thread["title"], threads.append(
"slug": thread["slug"], {
"is_pinned": thread["is_pinned"], "uid": thread["uid"],
"is_locked": thread["is_locked"], "title": thread["title"],
"view_count": thread["view_count"], "slug": thread["slug"],
"post_count": thread["post_count"], "is_pinned": thread["is_pinned"],
"created_at": thread["created_at"], "is_locked": thread["is_locked"],
"last_post_at": thread["last_post_at"], "view_count": thread["view_count"],
"author": { "post_count": thread["post_count"],
"uid": author["uid"], "created_at": thread["created_at"],
"username": author["username"], "last_post_at": thread["last_post_at"],
"nick": author["nick"], "author": {
"color": author["color"] "uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"],
},
"last_post_author": (
{
"uid": last_post_author["uid"],
"username": last_post_author["username"],
"nick": last_post_author["nick"],
"color": last_post_author["color"],
}
if last_post_author
else None
),
}
)
return web.json_response(
{
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"],
}, },
"last_post_author": { "threads": threads,
"uid": last_post_author["uid"], "page": page,
"username": last_post_author["username"], "hasMore": len(threads) == limit,
"nick": last_post_author["nick"], }
"color": last_post_author["color"] )
} if last_post_author else None
})
return web.json_response({
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"]
},
"threads": threads,
"page": page,
"hasMore": len(threads) == limit
})
async def create_thread(self): async def create_thread(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""POST /forum/api/forums/:slug/threads - Create new thread""" """POST /forum/api/forums/:slug/threads - Create new thread"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
slug = self.request.match_info["slug"] slug = self.request.match_info["slug"]
forum = await self.services.forum.get(slug=slug, is_active=True) forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum: if not forum:
return web.json_response({"error": "Forum not found"}, status=404) return web.json_response({"error": "Forum not found"}, status=404)
data = await self.request.json() data = await self.request.json()
title = data.get("title", "").strip() title = data.get("title", "").strip()
content = data.get("content", "").strip() content = data.get("content", "").strip()
if not title or not content: if not title or not content:
return web.json_response({"error": "Title and content required"}, status=400) return web.json_response(
{"error": "Title and content required"}, status=400
)
try: try:
thread, post = await self.services.thread.create_thread( thread, post = await self.services.thread.create_thread(
forum_uid=forum["uid"], forum_uid=forum["uid"],
title=title, title=title,
content=content, content=content,
created_by_uid=self.request.session["uid"] created_by_uid=self.request.session["uid"],
) )
return web.json_response({ return web.json_response(
"thread": { {
"uid": thread["uid"], "thread": {
"slug": thread["slug"], "uid": thread["uid"],
"forum_slug": forum["slug"] "slug": thread["slug"],
"forum_slug": forum["slug"],
}
} }
}) )
except Exception as e: except Exception as e:
return web.json_response({"error": str(e)}, status=400) return web.json_response({"error": str(e)}, status=400)
async def get_thread(self): async def get_thread(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""GET /forum/api/threads/:thread_slug - Get thread with posts""" """GET /forum/api/threads/:thread_slug - Get thread with posts"""
thread_slug = self.request.match_info["thread_slug"] thread_slug = self.request.match_info["thread_slug"]
thread = await self.services.thread.get(slug=thread_slug) thread = await self.services.thread.get(slug=thread_slug)
if not thread: if not thread:
return web.json_response({"error": "Thread not found"}, status=404) return web.json_response({"error": "Thread not found"}, status=404)
# Increment view count # Increment view count
await thread.increment_view_count() await thread.increment_view_count()
# Get forum # Get forum
forum = await self.services.forum.get(uid=thread["forum_uid"]) forum = await self.services.forum.get(uid=thread["forum_uid"])
# Get posts # Get posts
posts = [] posts = []
page = int(self.request.query.get("page", 1)) page = int(self.request.query.get("page", 1))
limit = 50 limit = 50
offset = (page - 1) * limit offset = (page - 1) * limit
current_user_uid = self.request.session.get("uid") current_user_uid = self.request.session.get("uid")
async for post in thread.get_posts(limit=limit, offset=offset): async for post in thread.get_posts(limit=limit, offset=offset):
author = await post.get_author() author = await post.get_author()
is_liked = False is_liked = False
if current_user_uid: if current_user_uid:
is_liked = await post.is_liked_by(current_user_uid) is_liked = await post.is_liked_by(current_user_uid)
posts.append({ posts.append(
"uid": post["uid"], {
"content": post["content"], "uid": post["uid"],
"created_at": post["created_at"], "content": post["content"],
"edited_at": post["edited_at"], "created_at": post["created_at"],
"is_first_post": post["is_first_post"], "edited_at": post["edited_at"],
"like_count": post["like_count"], "is_first_post": post["is_first_post"],
"is_liked": is_liked, "like_count": post["like_count"],
"author": { "is_liked": is_liked,
"uid": author["uid"], "author": {
"username": author["username"], "uid": author["uid"],
"nick": author["nick"], "username": author["username"],
"color": author["color"] "nick": author["nick"],
"color": author["color"],
},
} }
}) )
# Get thread author # Get thread author
thread_author = await self.services.user.get(uid=thread["created_by_uid"]) thread_author = await self.services.user.get(uid=thread["created_by_uid"])
return web.json_response({ return web.json_response(
"thread": { {
"uid": thread["uid"], "thread": {
"title": thread["title"], "uid": thread["uid"],
"slug": thread["slug"], "title": thread["title"],
"is_pinned": thread["is_pinned"], "slug": thread["slug"],
"is_locked": thread["is_locked"], "is_pinned": thread["is_pinned"],
"view_count": thread["view_count"], "is_locked": thread["is_locked"],
"post_count": thread["post_count"], "view_count": thread["view_count"],
"created_at": thread["created_at"], "post_count": thread["post_count"],
"author": { "created_at": thread["created_at"],
"uid": thread_author["uid"], "author": {
"username": thread_author["username"], "uid": thread_author["uid"],
"nick": thread_author["nick"], "username": thread_author["username"],
"color": thread_author["color"] "nick": thread_author["nick"],
} "color": thread_author["color"],
}, },
"forum": { },
"uid": forum["uid"], "forum": {
"name": forum["name"], "uid": forum["uid"],
"slug": forum["slug"] "name": forum["name"],
}, "slug": forum["slug"],
"posts": posts, },
"page": page, "posts": posts,
"hasMore": len(posts) == limit "page": page,
}) "hasMore": len(posts) == limit,
}
)
async def create_post(self): async def create_post(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/posts - Create new post""" """POST /forum/api/threads/:thread_uid/posts - Create new post"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"] thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.get(uid=thread_uid) thread = await self.services.thread.get(uid=thread_uid)
if not thread: if not thread:
return web.json_response({"error": "Thread not found"}, status=404) return web.json_response({"error": "Thread not found"}, status=404)
data = await self.request.json() data = await self.request.json()
content = data.get("content", "").strip() content = data.get("content", "").strip()
if not content: if not content:
return web.json_response({"error": "Content required"}, status=400) return web.json_response({"error": "Content required"}, status=400)
try: try:
post = await self.services.post.create_post( post = await self.services.post.create_post(
thread_uid=thread["uid"], thread_uid=thread["uid"],
forum_uid=thread["forum_uid"], forum_uid=thread["forum_uid"],
content=content, content=content,
created_by_uid=self.request.session["uid"] created_by_uid=self.request.session["uid"],
) )
author = await post.get_author() author = await post.get_author()
return web.json_response({ return web.json_response(
"post": { {
"uid": post["uid"], "post": {
"content": post["content"], "uid": post["uid"],
"created_at": post["created_at"], "content": post["content"],
"like_count": post["like_count"], "created_at": post["created_at"],
"is_liked": False, "like_count": post["like_count"],
"author": { "is_liked": False,
"uid": author["uid"], "author": {
"username": author["username"], "uid": author["uid"],
"nick": author["nick"], "username": author["username"],
"color": author["color"] "nick": author["nick"],
"color": author["color"],
},
} }
} }
}) )
except Exception as e: except Exception as e:
return web.json_response({"error": str(e)}, status=400) return web.json_response({"error": str(e)}, status=400)
async def edit_post(self): async def edit_post(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""PUT /forum/api/posts/:post_uid - Edit post""" """PUT /forum/api/posts/:post_uid - Edit post"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"] post_uid = self.request.match_info["post_uid"]
data = await self.request.json() data = await self.request.json()
content = data.get("content", "").strip() content = data.get("content", "").strip()
if not content: if not content:
return web.json_response({"error": "Content required"}, status=400) return web.json_response({"error": "Content required"}, status=400)
post = await self.services.post.edit_post( post = await self.services.post.edit_post(
post_uid=post_uid, post_uid=post_uid, content=content, user_uid=self.request.session["uid"]
content=content,
user_uid=self.request.session["uid"]
) )
if not post: if not post:
return web.json_response({"error": "Cannot edit post"}, status=403) return web.json_response({"error": "Cannot edit post"}, status=403)
return web.json_response({ return web.json_response(
"post": { {
"uid": post["uid"], "post": {
"content": post["content"], "uid": post["uid"],
"edited_at": post["edited_at"] "content": post["content"],
"edited_at": post["edited_at"],
}
} }
}) )
async def delete_post(self): async def delete_post(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""DELETE /forum/api/posts/:post_uid - Delete post""" """DELETE /forum/api/posts/:post_uid - Delete post"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"] post_uid = self.request.match_info["post_uid"]
success = await self.services.post.delete_post( success = await self.services.post.delete_post(
post_uid=post_uid, post_uid=post_uid, user_uid=self.request.session["uid"]
user_uid=self.request.session["uid"]
) )
if not success: if not success:
return web.json_response({"error": "Cannot delete post"}, status=403) return web.json_response({"error": "Cannot delete post"}, status=403)
return web.json_response({"success": True}) return web.json_response({"success": True})
async def toggle_like(self): async def toggle_like(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""POST /forum/api/posts/:post_uid/like - Toggle post like""" """POST /forum/api/posts/:post_uid/like - Toggle post like"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"] post_uid = self.request.match_info["post_uid"]
is_liked = await self.services.post_like.toggle_like( is_liked = await self.services.post_like.toggle_like(
post_uid=post_uid, post_uid=post_uid, user_uid=self.request.session["uid"]
user_uid=self.request.session["uid"]
) )
if is_liked is None: if is_liked is None:
return web.json_response({"error": "Failed to toggle like"}, status=400) return web.json_response({"error": "Failed to toggle like"}, status=400)
# Get updated post # Get updated post
post = await self.services.post.get(uid=post_uid) post = await self.services.post.get(uid=post_uid)
return web.json_response({ return web.json_response(
"is_liked": is_liked, {"is_liked": is_liked, "like_count": post["like_count"]}
"like_count": post["like_count"] )
})
async def toggle_pin(self): async def toggle_pin(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin""" """POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"] thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_pin( thread = await self.services.thread.toggle_pin(
thread_uid=thread_uid, thread_uid=thread_uid, user_uid=self.request.session["uid"]
user_uid=self.request.session["uid"]
) )
if not thread: if not thread:
return web.json_response({"error": "Cannot toggle pin"}, status=403) return web.json_response({"error": "Cannot toggle pin"}, status=403)
return web.json_response({"is_pinned": thread["is_pinned"]}) return web.json_response({"is_pinned": thread["is_pinned"]})
async def toggle_lock(self): async def toggle_lock(self):
request = self request = self
self = request.app self = request.app
setattr(self, "request", request) setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock""" """POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
if not self.request.session.get("logged_in"): if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401) return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"] thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_lock( thread = await self.services.thread.toggle_lock(
thread_uid=thread_uid, thread_uid=thread_uid, user_uid=self.request.session["uid"]
user_uid=self.request.session["uid"]
) )
if not thread: if not thread:
return web.json_response({"error": "Cannot toggle lock"}, status=403) return web.json_response({"error": "Cannot toggle lock"}, status=403)
return web.json_response({"is_locked": thread["is_locked"]}) return web.json_response({"is_locked": thread["is_locked"]})
# views/forum_websocket.py # views/forum_websocket.py
class ForumWebSocketView(BaseView): class ForumWebSocketView(BaseView):
"""WebSocket view for real-time forum updates""" """WebSocket view for real-time forum updates"""
async def get(self): async def get(self):
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(self.request) await ws.prepare(self.request)
# Store WebSocket connection # Store WebSocket connection
ws_id = self.services.forum.generate_uid() ws_id = self.services.forum.generate_uid()
if not hasattr(self.app, 'forum_websockets'): if not hasattr(self.app, "forum_websockets"):
self.app.forum_websockets = {} self.app.forum_websockets = {}
user_uid = self.request.session.get("uid") user_uid = self.request.session.get("uid")
self.app.forum_websockets[ws_id] = { self.app.forum_websockets[ws_id] = {
"ws": ws, "ws": ws,
"user_uid": user_uid, "user_uid": user_uid,
"subscriptions": set() "subscriptions": set(),
} }
try: try:
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.TEXT: if msg.type == web.WSMsgType.TEXT:
@ -483,68 +493,76 @@ class ForumWebSocketView(BaseView):
# Clean up # Clean up
if ws_id in self.app.forum_websockets: if ws_id in self.app.forum_websockets:
del self.app.forum_websockets[ws_id] del self.app.forum_websockets[ws_id]
return ws return ws
async def handle_ws_message(self, ws_id, data): async def handle_ws_message(self, ws_id, data):
"""Handle incoming WebSocket messages""" """Handle incoming WebSocket messages"""
action = data.get("action") action = data.get("action")
if action == "subscribe": if action == "subscribe":
# Subscribe to forum/thread updates # Subscribe to forum/thread updates
target_type = data.get("type") # "forum" or "thread" target_type = data.get("type") # "forum" or "thread"
target_id = data.get("id") target_id = data.get("id")
if target_type and target_id: if target_type and target_id:
subscription = f"{target_type}:{target_id}" subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].add(subscription) self.app.forum_websockets[ws_id]["subscriptions"].add(subscription)
# Send confirmation # Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"] ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({ await ws.send_str(
"type": "subscribed", json.dumps({"type": "subscribed", "subscription": subscription})
"subscription": subscription )
}))
elif action == "unsubscribe": elif action == "unsubscribe":
target_type = data.get("type") target_type = data.get("type")
target_id = data.get("id") target_id = data.get("id")
if target_type and target_id: if target_type and target_id:
subscription = f"{target_type}:{target_id}" subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription) self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription)
# Send confirmation # Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"] ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({ await ws.send_str(
"type": "unsubscribed", json.dumps({"type": "unsubscribed", "subscription": subscription})
"subscription": subscription )
}))
@staticmethod @staticmethod
async def broadcast_update(app, event_type, data): async def broadcast_update(app, event_type, data):
"""Broadcast updates to subscribed WebSocket clients""" """Broadcast updates to subscribed WebSocket clients"""
if not hasattr(app, 'forum_websockets'): if not hasattr(app, "forum_websockets"):
return return
# Determine subscription targets based on event # Determine subscription targets based on event
targets = set() targets = set()
if event_type in ["thread_created", "post_created", "post_edited", "post_deleted"]: if event_type in [
"thread_created",
"post_created",
"post_edited",
"post_deleted",
]:
if "forum_uid" in data: if "forum_uid" in data:
targets.add(f"forum:{data['forum_uid']}") targets.add(f"forum:{data['forum_uid']}")
if event_type in ["post_created", "post_edited", "post_deleted", "post_liked", "post_unliked"]: if event_type in [
"post_created",
"post_edited",
"post_deleted",
"post_liked",
"post_unliked",
]:
if "thread_uid" in data: if "thread_uid" in data:
targets.add(f"thread:{data['thread_uid']}") targets.add(f"thread:{data['thread_uid']}")
# Send to subscribed clients # Send to subscribed clients
for ws_id, ws_data in app.forum_websockets.items(): for ws_id, ws_data in app.forum_websockets.items():
if ws_data["subscriptions"] & targets: if ws_data["subscriptions"] & targets:
try: try:
await ws_data["ws"].send_str(json.dumps({ await ws_data["ws"].send_str(
"type": event_type, json.dumps({"type": event_type, "data": data})
"data": data )
}))
except Exception: except Exception:
pass pass

View File

@ -30,7 +30,7 @@ from snek.system.view import BaseFormView
class RegisterFormView(BaseFormView): class RegisterFormView(BaseFormView):
login_required = False login_required = False
form = RegisterForm form = RegisterForm

View File

@ -6,18 +6,18 @@
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions. # MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
from snek.system.stats import update_websocket_stats
import asyncio import asyncio
import json import json
import logging import logging
import time
import traceback import traceback
import random
from aiohttp import web from aiohttp import web
from snek.system.model import now from snek.system.model import now
from snek.system.profiler import Profiler from snek.system.stats import update_websocket_stats
from snek.system.view import BaseView from snek.system.view import BaseView
import time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -203,7 +203,7 @@ class RPCView(BaseView):
} }
) )
return channels return channels
async def clear_channel(self, channel_uid): async def clear_channel(self, channel_uid):
self._require_login() self._require_login()
user = await self.services.user.get(uid=self.user_uid) user = await self.services.user.get(uid=self.user_uid)
@ -211,41 +211,49 @@ class RPCView(BaseView):
raise Exception("Not allowed") raise Exception("Not allowed")
return await self.services.channel_message.clear(channel_uid) return await self.services.channel_message.clear(channel_uid)
async def write_container(self, channel_uid, content,timeout=3): async def write_container(self, channel_uid, content, timeout=3):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid channel_uid=channel_uid, user_uid=self.user_uid
) )
if not channel_member: if not channel_member:
raise Exception("Not allowed") raise Exception("Not allowed")
container_name = await self.services.container.get_container_name(channel_uid) container_name = await self.services.container.get_container_name(
channel_uid
)
class SessionCall: class SessionCall:
def __init__(self, app,channel_uid_uid, container_name): def __init__(self, app, channel_uid_uid, container_name):
self.app = app self.app = app
self.channel_uid = channel_uid self.channel_uid = channel_uid
self.container_name = container_name self.container_name = container_name
self.time_last_output = time.time() self.time_last_output = time.time()
self.output = b'' self.output = b""
async def stdout_event_handler(self, data): async def stdout_event_handler(self, data):
self.time_last_output = time.time() self.time_last_output = time.time()
self.output += data self.output += data
return True return True
async def communicate(self,content, timeout=3): async def communicate(self, content, timeout=3):
await self.app.services.container.add_event_listener(self.container_name, "stdout", self.stdout_event_handler) await self.app.services.container.add_event_listener(
await self.app.services.container.write_stdin(self.channel_uid, content) self.container_name, "stdout", self.stdout_event_handler
)
await self.app.services.container.write_stdin(
self.channel_uid, content
)
while time.time() - self.time_last_output < timeout: while time.time() - self.time_last_output < timeout:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
await self.app.services.container.remove_event_listener(self.container_name, "stdout", self.stdout_event_handler) await self.app.services.container.remove_event_listener(
self.container_name, "stdout", self.stdout_event_handler
)
return self.output return self.output
sc = SessionCall(self, channel_uid,container_name) sc = SessionCall(self, channel_uid, container_name)
return (await sc.communicate(content)).decode("utf-8","ignore") return (await sc.communicate(content)).decode("utf-8", "ignore")
async def get_container(self, channel_uid): async def get_container(self, channel_uid):
self._require_login() self._require_login()
@ -258,44 +266,44 @@ class RPCView(BaseView):
result = None result = None
if container: if container:
result = { result = {
"name": await self.services.container.get_container_name(channel_uid), "name": await self.services.container.get_container_name(
channel_uid
),
"cpus": container["deploy"]["resources"]["limits"]["cpus"], "cpus": container["deploy"]["resources"]["limits"]["cpus"],
"memory": container["deploy"]["resources"]["limits"]["memory"], "memory": container["deploy"]["resources"]["limits"]["memory"],
"image": "ubuntu:latest", "image": "ubuntu:latest",
"volumes": [], "volumes": [],
"status": container["status"] "status": container["status"],
} }
return result return result
async def _finalize_message_task(self, message_uid):
async def _finalize_message_task(self,message_uid):
try: try:
for _ in range(7): for _ in range(7):
await asyncio.sleep(1) await asyncio.sleep(1)
if not self._finalize_task: if not self._finalize_task:
return return
except asyncio.exceptions.CancelledError: except asyncio.exceptions.CancelledError:
print("Finalization cancelled.") print("Finalization cancelled.")
return return
self._finalize_task = None self._finalize_task = None
message = await self.services.channel_message.get(message_uid, is_final=False) message = await self.services.channel_message.get(
message_uid, is_final=False
)
if not message: if not message:
return False return False
if message["user_uid"] != self.user_uid: if message["user_uid"] != self.user_uid:
raise Exception("Not allowed") raise Exception("Not allowed")
if message["is_final"]: if message["is_final"]:
return True return True
if not message["is_final"]: if not message["is_final"]:
await self.services.chat.finalize(message["uid"]) await self.services.chat.finalize(message["uid"])
return True return True
async def _queue_finalize_message(self, message_uid): async def _queue_finalize_message(self, message_uid):
if self._finalize_task: if self._finalize_task:
self._finalize_task.cancel() self._finalize_task.cancel()
@ -305,18 +313,22 @@ class RPCView(BaseView):
async def send_message(self, channel_uid, message, is_final=True): async def send_message(self, channel_uid, message, is_final=True):
self._require_login() self._require_login()
message = message.strip() message = message.strip()
if not is_final: if not is_final:
if_final = False check_message = await self.services.channel_message.get(
check_message = await self.services.channel_message.get(channel_uid=channel_uid, user_uid=self.user_uid,is_final=False,deleted_at=None) channel_uid=channel_uid,
user_uid=self.user_uid,
is_final=False,
deleted_at=None,
)
if check_message: if check_message:
await self._queue_finalize_message(check_message["uid"]) await self._queue_finalize_message(check_message["uid"])
return await self.update_message_text(check_message["uid"], message) return await self.update_message_text(check_message["uid"], message)
if not message: if not message:
return return
message = await self.services.chat.send( message = await self.services.chat.send(
self.user_uid, channel_uid, message, is_final self.user_uid, channel_uid, message, is_final
@ -328,7 +340,6 @@ class RPCView(BaseView):
return message["uid"] return message["uid"]
async def start_container(self, channel_uid): async def start_container(self, channel_uid):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
@ -355,8 +366,6 @@ class RPCView(BaseView):
if not channel_member: if not channel_member:
raise Exception("Not allowed") raise Exception("Not allowed")
return await self.services.container.get_status(channel_uid) return await self.services.container.get_status(channel_uid)
async def finalize_message(self, message_uid): async def finalize_message(self, message_uid):
self._require_login() self._require_login()
@ -373,8 +382,8 @@ class RPCView(BaseView):
return True return True
async def update_message_text(self, message_uid, text): async def update_message_text(self, message_uid, text):
#async with self.app.no_save(): # async with self.app.no_save():
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
@ -403,7 +412,6 @@ class RPCView(BaseView):
"success": False, "success": False,
} }
if not text: if not text:
message["deleted_at"] = now() message["deleted_at"] = now()
else: else:
@ -427,9 +435,6 @@ class RPCView(BaseView):
return {"success": True} return {"success": True}
async def clear_channel(self, channel_uid): async def clear_channel(self, channel_uid):
self._require_login() self._require_login()
user = await self.services.user.get(uid=self.user_uid) user = await self.services.user.get(uid=self.user_uid)
@ -438,11 +443,10 @@ class RPCView(BaseView):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
raise Exception("Channel not found") raise Exception("Channel not found")
channel['history_start'] = datetime.now() channel["history_start"] = datetime.now()
await self.services.channel.save(channel) await self.services.channel.save(channel)
return await self.services.channel_message.clear(channel_uid) return await self.services.channel_message.clear(channel_uid)
async def echo(self, *args): async def echo(self, *args):
self._require_login() self._require_login()
return args return args
@ -528,10 +532,10 @@ class RPCView(BaseView):
async def _send_json(self, obj): async def _send_json(self, obj):
try: try:
await self.ws.send_str(json.dumps(obj, default=str)) await self.ws.send_str(json.dumps(obj, default=str))
except Exception as ex: except Exception:
await self.services.socket.delete(self.ws) await self.services.socket.delete(self.ws)
await self.ws.close() await self.ws.close()
async def get_online_users(self, channel_uid): async def get_online_users(self, channel_uid):
self._require_login() self._require_login()

View File

@ -38,10 +38,10 @@ class WebView(BaseView):
channel = await self.services.channel.get( channel = await self.services.channel.get(
uid=self.request.match_info.get("channel") uid=self.request.match_info.get("channel")
) )
print(self.session.get("uid"),"ZZZZZZZZZZ") print(self.session.get("uid"), "ZZZZZZZZZZ")
qq = await self.services.user.get(uid=self.session.get("uid")) qq = await self.services.user.get(uid=self.session.get("uid"))
print("GGGGGGGGGG",qq) print("GGGGGGGGGG", qq)
if not channel: if not channel:
user = await self.services.user.get( user = await self.services.user.get(
uid=self.request.match_info.get("channel") uid=self.request.match_info.get("channel")

View File

@ -1,22 +1,24 @@
import logging
import pathlib
import base64 import base64
import datetime import datetime
import hashlib
import json import json
import logging
import mimetypes import mimetypes
import os import os
import pathlib
import shutil import shutil
import uuid import uuid
from urllib.parse import urlparse
import aiofiles import aiofiles
import aiohttp import aiohttp
import aiohttp.web import aiohttp.web
import hashlib
from app.cache import time_cache_async from app.cache import time_cache_async
from lxml import etree from lxml import etree
from urllib.parse import urlparse
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@aiohttp.web.middleware @aiohttp.web.middleware
async def debug_middleware(request, handler): async def debug_middleware(request, handler):
print(request.method, request.path, request.headers) print(request.method, request.path, request.headers)
@ -28,6 +30,7 @@ async def debug_middleware(request, handler):
pass pass
return result return result
class WebdavApplication(aiohttp.web.Application): class WebdavApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs): def __init__(self, parent, *args, **kwargs):
middlewares = [debug_middleware] middlewares = [debug_middleware]
@ -78,7 +81,7 @@ class WebdavApplication(aiohttp.web.Application):
return request["user"] return request["user"]
async def is_locked(self, abs_path, request): async def is_locked(self, abs_path, request):
rel_path = abs_path.relative_to(request["home"]).as_posix() abs_path.relative_to(request["home"]).as_posix()
path = abs_path path = abs_path
while path != request["home"].parent: while path != request["home"].parent:
rel = path.relative_to(request["home"]).as_posix() rel = path.relative_to(request["home"]).as_posix()
@ -122,14 +125,20 @@ class WebdavApplication(aiohttp.web.Application):
return aiohttp.web.Response(status=403, text="Cannot download a directory") return aiohttp.web.Response(status=403, text="Cannot download a directory")
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
content_type, _ = mimetypes.guess_type(str(abs_path)) content_type, _ = mimetypes.guess_type(str(abs_path))
content_type = content_type or "application/octet-stream" content_type = content_type or "application/octet-stream"
etag = f'"{hashlib.sha1(str(abs_path.stat().st_mtime).encode()).hexdigest()}"' etag = f'"{hashlib.sha1(str(abs_path.stat().st_mtime).encode()).hexdigest()}"'
headers = {"Content-Type": content_type, "ETag": etag} headers = {"Content-Type": content_type, "ETag": etag}
return aiohttp.web.FileResponse(path=str(abs_path), headers=headers, chunk_size=8192) return aiohttp.web.FileResponse(
path=str(abs_path), headers=headers, chunk_size=8192
)
async def handle_head(self, request): async def handle_head(self, request):
if not await self.authenticate(request): if not await self.authenticate(request):
@ -141,10 +150,16 @@ class WebdavApplication(aiohttp.web.Application):
if not abs_path.exists(): if not abs_path.exists():
return aiohttp.web.Response(status=404, text="File not found") return aiohttp.web.Response(status=404, text="File not found")
if abs_path.is_dir(): if abs_path.is_dir():
return aiohttp.web.Response(status=403, text="Cannot get metadata for a directory") return aiohttp.web.Response(
status=403, text="Cannot get metadata for a directory"
)
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
content_type, _ = mimetypes.guess_type(str(abs_path)) content_type, _ = mimetypes.guess_type(str(abs_path))
@ -168,7 +183,11 @@ class WebdavApplication(aiohttp.web.Application):
abs_path = request["home"] / requested_path abs_path = request["home"] / requested_path
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.parent.mkdir(parents=True, exist_ok=True)
@ -186,7 +205,11 @@ class WebdavApplication(aiohttp.web.Application):
abs_path = request["home"] / requested_path abs_path = request["home"] / requested_path
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
if abs_path.is_file(): if abs_path.is_file():
@ -199,7 +222,9 @@ class WebdavApplication(aiohttp.web.Application):
del self.locks[rel_path] del self.locks[rel_path]
return aiohttp.web.Response(status=204) return aiohttp.web.Response(status=204)
elif abs_path.is_dir(): elif abs_path.is_dir():
if await self.has_descendant_locks(abs_path.relative_to(request["home"]).as_posix(), True): if await self.has_descendant_locks(
abs_path.relative_to(request["home"]).as_posix(), True
):
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
shutil.rmtree(abs_path) shutil.rmtree(abs_path)
rel_path = abs_path.relative_to(request["home"]).as_posix() rel_path = abs_path.relative_to(request["home"]).as_posix()
@ -217,7 +242,11 @@ class WebdavApplication(aiohttp.web.Application):
abs_path = request["home"] / requested_path abs_path = request["home"] / requested_path
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
if abs_path.exists(): if abs_path.exists():
@ -235,15 +264,25 @@ class WebdavApplication(aiohttp.web.Application):
if not destination: if not destination:
return aiohttp.web.Response(status=400, text="Destination header missing") return aiohttp.web.Response(status=400, text="Destination header missing")
parsed = urlparse(destination) parsed = urlparse(destination)
if parsed.scheme and (parsed.scheme != request.scheme or parsed.netloc != request.host): if parsed.scheme and (
parsed.scheme != request.scheme or parsed.netloc != request.host
):
return aiohttp.web.Response(status=502, text="Bad Gateway") return aiohttp.web.Response(status=502, text="Bad Gateway")
dest_rel = parsed.path[len(self.relative_url)+1:] if parsed.path.startswith(self.relative_url) else "" dest_rel = (
parsed.path[len(self.relative_url) + 1 :]
if parsed.path.startswith(self.relative_url)
else ""
)
dest_path = request["home"] / dest_rel dest_path = request["home"] / dest_rel
if not src_path.exists(): if not src_path.exists():
return aiohttp.web.Response(status=404, text="Source not found") return aiohttp.web.Response(status=404, text="Source not found")
src_lock = await self.is_locked(src_path, request) src_lock = await self.is_locked(src_path, request)
dest_lock = await self.is_locked(dest_path, request) dest_lock = await self.is_locked(dest_path, request)
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if src_lock and src_lock["token"] != submitted: if src_lock and src_lock["token"] != submitted:
return aiohttp.web.Response(status=423, text="Source Locked") return aiohttp.web.Response(status=423, text="Source Locked")
if dest_lock and dest_lock["token"] != submitted: if dest_lock and dest_lock["token"] != submitted:
@ -277,15 +316,25 @@ class WebdavApplication(aiohttp.web.Application):
if not destination: if not destination:
return aiohttp.web.Response(status=400, text="Destination header missing") return aiohttp.web.Response(status=400, text="Destination header missing")
parsed = urlparse(destination) parsed = urlparse(destination)
if parsed.scheme and (parsed.scheme != request.scheme or parsed.netloc != request.host): if parsed.scheme and (
parsed.scheme != request.scheme or parsed.netloc != request.host
):
return aiohttp.web.Response(status=502, text="Bad Gateway") return aiohttp.web.Response(status=502, text="Bad Gateway")
dest_rel = parsed.path[len(self.relative_url)+1:] if parsed.path.startswith(self.relative_url) else "" dest_rel = (
parsed.path[len(self.relative_url) + 1 :]
if parsed.path.startswith(self.relative_url)
else ""
)
dest_path = request["home"] / dest_rel dest_path = request["home"] / dest_rel
if not src_path.exists(): if not src_path.exists():
return aiohttp.web.Response(status=404, text="Source not found") return aiohttp.web.Response(status=404, text="Source not found")
src_lock = await self.is_locked(src_path, request) src_lock = await self.is_locked(src_path, request)
dest_lock = await self.is_locked(dest_path, request) dest_lock = await self.is_locked(dest_path, request)
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if src_lock and src_lock["token"] != submitted: if src_lock and src_lock["token"] != submitted:
return aiohttp.web.Response(status=423, text="Source Locked") return aiohttp.web.Response(status=423, text="Source Locked")
if dest_lock and dest_lock["token"] != submitted: if dest_lock and dest_lock["token"] != submitted:
@ -347,7 +396,16 @@ class WebdavApplication(aiohttp.web.Application):
statvfs = await loop.run_in_executor(None, os.statvfs, path) statvfs = await loop.run_in_executor(None, os.statvfs, path)
return statvfs.f_bavail * statvfs.f_frsize return statvfs.f_bavail * statvfs.f_frsize
async def create_propfind_node(self, request, response_xml, full_path, depth, requested_props, propname_only, all_props): async def create_propfind_node(
self,
request,
response_xml,
full_path,
depth,
requested_props,
propname_only,
all_props,
):
abs_path = pathlib.Path(full_path) abs_path = pathlib.Path(full_path)
relative_path = abs_path.relative_to(request["home"]).as_posix() relative_path = abs_path.relative_to(request["home"]).as_posix()
href_path = f"{self.relative_url}/{relative_path}".strip(".") href_path = f"{self.relative_url}/{relative_path}".strip(".")
@ -379,7 +437,9 @@ class WebdavApplication(aiohttp.web.Application):
prop_notfound = None prop_notfound = None
for prop_tag, prop_nsmap in requested_props: for prop_tag, prop_nsmap in requested_props:
if prop_tag in standard_props: if prop_tag in standard_props:
await self.add_standard_property(prop_ok, prop_tag, full_path, request) await self.add_standard_property(
prop_ok, prop_tag, full_path, request
)
found_props.append(prop_tag) found_props.append(prop_tag)
elif prop_tag in custom_properties: elif prop_tag in custom_properties:
if propname_only: if propname_only:
@ -391,23 +451,31 @@ class WebdavApplication(aiohttp.web.Application):
else: else:
if propstat_notfound is None: if propstat_notfound is None:
propstat_notfound = etree.SubElement(response, "{DAV:}propstat") propstat_notfound = etree.SubElement(response, "{DAV:}propstat")
prop_notfound = etree.SubElement(propstat_notfound, "{DAV:}prop") prop_notfound = etree.SubElement(
propstat_notfound, "{DAV:}prop"
)
etree.SubElement(prop_notfound, prop_tag, nsmap=prop_nsmap) etree.SubElement(prop_notfound, prop_tag, nsmap=prop_nsmap)
if propstat_notfound is not None: if propstat_notfound is not None:
etree.SubElement(propstat_notfound, "{DAV:}status").text = "HTTP/1.1 404 Not Found" etree.SubElement(propstat_notfound, "{DAV:}status").text = (
"HTTP/1.1 404 Not Found"
)
else: else:
for prop_name in standard_props: for prop_name in standard_props:
if propname_only: if propname_only:
etree.SubElement(prop_ok, prop_name) etree.SubElement(prop_ok, prop_name)
else: else:
await self.add_standard_property(prop_ok, prop_name, full_path, request) await self.add_standard_property(
prop_ok, prop_name, full_path, request
)
for prop_name, prop_value in custom_properties.items(): for prop_name, prop_value in custom_properties.items():
if propname_only: if propname_only:
if prop_name.startswith("{"): if prop_name.startswith("{"):
ns_end = prop_name.find("}") ns_end = prop_name.find("}")
ns = prop_name[1:ns_end] ns = prop_name[1:ns_end]
local_name = prop_name[ns_end+1:] local_name = prop_name[ns_end + 1 :]
prop_nsmap = {"D": "DAV:", None: ns} if ns != "DAV:" else {"D": "DAV:"} prop_nsmap = (
{"D": "DAV:", None: ns} if ns != "DAV:" else {"D": "DAV:"}
)
etree.SubElement(prop_ok, local_name, nsmap=prop_nsmap) etree.SubElement(prop_ok, local_name, nsmap=prop_nsmap)
else: else:
etree.SubElement(prop_ok, prop_name) etree.SubElement(prop_ok, prop_name)
@ -417,9 +485,19 @@ class WebdavApplication(aiohttp.web.Application):
etree.SubElement(propstat_ok, "{DAV:}status").text = "HTTP/1.1 200 OK" etree.SubElement(propstat_ok, "{DAV:}status").text = "HTTP/1.1 200 OK"
if abs_path.is_dir() and depth > 0: if abs_path.is_dir() and depth > 0:
for item in abs_path.iterdir(): for item in abs_path.iterdir():
if item.name.startswith(".") and item.name.endswith(".webdav_props.json"): if item.name.startswith(".") and item.name.endswith(
".webdav_props.json"
):
continue continue
await self.create_propfind_node(request, response_xml, item, depth - 1, requested_props, propname_only, all_props) await self.create_propfind_node(
request,
response_xml,
item,
depth - 1,
requested_props,
propname_only,
all_props,
)
async def add_standard_property(self, prop_elem, prop_name, full_path, request): async def add_standard_property(self, prop_elem, prop_name, full_path, request):
if prop_name == "{DAV:}resourcetype": if prop_name == "{DAV:}resourcetype":
@ -445,14 +523,22 @@ class WebdavApplication(aiohttp.web.Application):
etree.SubElement(locktype, f"{{DAV:}}{lock['type']}") etree.SubElement(locktype, f"{{DAV:}}{lock['type']}")
lockscope = etree.SubElement(activelock, "{DAV:}lockscope") lockscope = etree.SubElement(activelock, "{DAV:}lockscope")
etree.SubElement(lockscope, f"{{DAV:}}{lock['scope']}") etree.SubElement(lockscope, f"{{DAV:}}{lock['scope']}")
etree.SubElement(activelock, "{DAV:}depth").text = lock['depth'].capitalize() etree.SubElement(activelock, "{DAV:}depth").text = lock[
if lock['owner']: "depth"
owner = etree.fromstring(lock['owner']) ].capitalize()
if lock["owner"]:
owner = etree.fromstring(lock["owner"])
activelock.append(owner) activelock.append(owner)
timeout_str = "Infinite" if lock['timeout'] is None else f"Second-{lock['timeout']}" timeout_str = (
"Infinite"
if lock["timeout"] is None
else f"Second-{lock['timeout']}"
)
etree.SubElement(activelock, "{DAV:}timeout").text = timeout_str etree.SubElement(activelock, "{DAV:}timeout").text = timeout_str
locktoken = etree.SubElement(activelock, "{DAV:}locktoken") locktoken = etree.SubElement(activelock, "{DAV:}locktoken")
etree.SubElement(locktoken, "{DAV:}href").text = f"opaquelocktoken:{lock['token']}" etree.SubElement(locktoken, "{DAV:}href").text = (
f"opaquelocktoken:{lock['token']}"
)
elif prop_name == "{DAV:}supportedlock": elif prop_name == "{DAV:}supportedlock":
supported_lock = etree.SubElement(prop_elem, "{DAV:}supportedlock") supported_lock = etree.SubElement(prop_elem, "{DAV:}supportedlock")
lock_entry_1 = etree.SubElement(supported_lock, "{DAV:}lockentry") lock_entry_1 = etree.SubElement(supported_lock, "{DAV:}lockentry")
@ -466,11 +552,17 @@ class WebdavApplication(aiohttp.web.Application):
lock_type_2 = etree.SubElement(lock_entry_2, "{DAV:}locktype") lock_type_2 = etree.SubElement(lock_entry_2, "{DAV:}locktype")
etree.SubElement(lock_type_2, "{DAV:}write") etree.SubElement(lock_type_2, "{DAV:}write")
elif prop_name == "{DAV:}quota-used-bytes": elif prop_name == "{DAV:}quota-used-bytes":
size = await self.get_file_size(full_path) if full_path.is_file() else await self.get_directory_size(full_path) size = (
await self.get_file_size(full_path)
if full_path.is_file()
else await self.get_directory_size(full_path)
)
etree.SubElement(prop_elem, "{DAV:}quota-used-bytes").text = str(size) etree.SubElement(prop_elem, "{DAV:}quota-used-bytes").text = str(size)
elif prop_name == "{DAV:}quota-available-bytes": elif prop_name == "{DAV:}quota-available-bytes":
free_space = await self.get_disk_free_space(str(full_path)) free_space = await self.get_disk_free_space(str(full_path))
etree.SubElement(prop_elem, "{DAV:}quota-available-bytes").text = str(free_space) etree.SubElement(prop_elem, "{DAV:}quota-available-bytes").text = str(
free_space
)
elif prop_name == "{DAV:}getcontentlength" and full_path.is_file(): elif prop_name == "{DAV:}getcontentlength" and full_path.is_file():
size = await self.get_file_size(full_path) size = await self.get_file_size(full_path)
etree.SubElement(prop_elem, "{DAV:}getcontentlength").text = str(size) etree.SubElement(prop_elem, "{DAV:}getcontentlength").text = str(size)
@ -479,7 +571,9 @@ class WebdavApplication(aiohttp.web.Application):
if mimetype: if mimetype:
etree.SubElement(prop_elem, "{DAV:}getcontenttype").text = mimetype etree.SubElement(prop_elem, "{DAV:}getcontenttype").text = mimetype
elif prop_name == "{DAV:}getetag" and full_path.is_file(): elif prop_name == "{DAV:}getetag" and full_path.is_file():
etag = f'"{hashlib.sha1(str(full_path.stat().st_mtime).encode()).hexdigest()}"' etag = (
f'"{hashlib.sha1(str(full_path.stat().st_mtime).encode()).hexdigest()}"'
)
etree.SubElement(prop_elem, "{DAV:}getetag").text = etag etree.SubElement(prop_elem, "{DAV:}getetag").text = etag
async def handle_propfind(self, request): async def handle_propfind(self, request):
@ -520,9 +614,21 @@ class WebdavApplication(aiohttp.web.Application):
pass pass
nsmap = {"D": "DAV:"} nsmap = {"D": "DAV:"}
response_xml = etree.Element("{DAV:}multistatus", nsmap=nsmap) response_xml = etree.Element("{DAV:}multistatus", nsmap=nsmap)
await self.create_propfind_node(request, response_xml, abs_path, depth, requested_props, propname_only, all_props) await self.create_propfind_node(
xml_output = etree.tostring(response_xml, encoding="utf-8", xml_declaration=True).decode() request,
return aiohttp.web.Response(status=207, text=xml_output, content_type="application/xml") response_xml,
abs_path,
depth,
requested_props,
propname_only,
all_props,
)
xml_output = etree.tostring(
response_xml, encoding="utf-8", xml_declaration=True
).decode()
return aiohttp.web.Response(
status=207, text=xml_output, content_type="application/xml"
)
async def handle_proppatch(self, request): async def handle_proppatch(self, request):
if not await self.authenticate(request): if not await self.authenticate(request):
@ -535,7 +641,11 @@ class WebdavApplication(aiohttp.web.Application):
return aiohttp.web.Response(status=404, text="Resource not found") return aiohttp.web.Response(status=404, text="Resource not found")
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
body = await request.read() body = await request.read()
@ -549,7 +659,11 @@ class WebdavApplication(aiohttp.web.Application):
response = etree.SubElement(response_xml, "{DAV:}response") response = etree.SubElement(response_xml, "{DAV:}response")
href = etree.SubElement(response, "{DAV:}href") href = etree.SubElement(response, "{DAV:}href")
relative_path = abs_path.relative_to(request["home"]).as_posix() relative_path = abs_path.relative_to(request["home"]).as_posix()
href_path = f"{self.relative_url}/{relative_path}".strip(".").replace("./", "/").replace("//", "/") href_path = (
f"{self.relative_url}/{relative_path}".strip(".")
.replace("./", "/")
.replace("//", "/")
)
href.text = href_path href.text = href_path
set_elem = root.find(".//{DAV:}set") set_elem = root.find(".//{DAV:}set")
if set_elem is not None: if set_elem is not None:
@ -561,14 +675,14 @@ class WebdavApplication(aiohttp.web.Application):
if child.tag.startswith("{"): if child.tag.startswith("{"):
ns_end = child.tag.find("}") ns_end = child.tag.find("}")
ns = child.tag[1:ns_end] ns = child.tag[1:ns_end]
local_name = child.tag[ns_end+1:] local_name = child.tag[ns_end + 1 :]
else: else:
ns = "DAV:" ns = "DAV:"
local_name = child.tag local_name = child.tag
prop_name = f"{{{ns}}}{local_name}" prop_name = f"{{{ns}}}{local_name}"
prop_value = child.text or "" prop_value = child.text or ""
properties[prop_name] = prop_value properties[prop_name] = prop_value
elem = etree.SubElement(prop, child.tag, nsmap=child.nsmap) etree.SubElement(prop, child.tag, nsmap=child.nsmap)
etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK" etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK"
remove_elem = root.find(".//{DAV:}remove") remove_elem = root.find(".//{DAV:}remove")
if remove_elem is not None: if remove_elem is not None:
@ -580,7 +694,7 @@ class WebdavApplication(aiohttp.web.Application):
if child.tag.startswith("{"): if child.tag.startswith("{"):
ns_end = child.tag.find("}") ns_end = child.tag.find("}")
ns = child.tag[1:ns_end] ns = child.tag[1:ns_end]
local_name = child.tag[ns_end+1:] local_name = child.tag[ns_end + 1 :]
else: else:
ns = "DAV:" ns = "DAV:"
local_name = child.tag local_name = child.tag
@ -590,14 +704,18 @@ class WebdavApplication(aiohttp.web.Application):
status = "HTTP/1.1 200 OK" status = "HTTP/1.1 200 OK"
else: else:
status = "HTTP/1.1 404 Not Found" status = "HTTP/1.1 404 Not Found"
elem = etree.SubElement(prop, child.tag, nsmap=child.nsmap) etree.SubElement(prop, child.tag, nsmap=child.nsmap)
etree.SubElement(propstat, "{DAV:}status").text = status etree.SubElement(propstat, "{DAV:}status").text = status
if properties: if properties:
await self.save_properties(abs_path, properties) await self.save_properties(abs_path, properties)
else: else:
await self.delete_properties_file(abs_path) await self.delete_properties_file(abs_path)
xml_output = etree.tostring(response_xml, encoding="utf-8", xml_declaration=True).decode() xml_output = etree.tostring(
return aiohttp.web.Response(status=207, text=xml_output, content_type="application/xml") response_xml, encoding="utf-8", xml_declaration=True
).decode()
return aiohttp.web.Response(
status=207, text=xml_output, content_type="application/xml"
)
except Exception as e: except Exception as e:
logging.error(f"PROPPATCH error: {e}") logging.error(f"PROPPATCH error: {e}")
return aiohttp.web.Response(status=400, text="Bad Request") return aiohttp.web.Response(status=400, text="Bad Request")
@ -639,11 +757,19 @@ class WebdavApplication(aiohttp.web.Application):
scope = "shared" scope = "shared"
locktype = "write" locktype = "write"
owner_elem = lockinfo.find("{DAV:}owner") owner_elem = lockinfo.find("{DAV:}owner")
owner_xml = etree.tostring(owner_elem, encoding="unicode") if owner_elem is not None else "" owner_xml = (
etree.tostring(owner_elem, encoding="unicode")
if owner_elem is not None
else ""
)
covering_lock = await self.is_locked(abs_path, request) covering_lock = await self.is_locked(abs_path, request)
if covering_lock: if covering_lock:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
if abs_path.is_dir() and depth == "infinity" and await self.has_descendant_locks(resource, True): if (
abs_path.is_dir()
and depth == "infinity"
and await self.has_descendant_locks(resource, True)
):
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
token = str(uuid.uuid4()) token = str(uuid.uuid4())
lock_dict = { lock_dict = {
@ -653,12 +779,17 @@ class WebdavApplication(aiohttp.web.Application):
"owner": owner_xml, "owner": owner_xml,
"depth": depth, "depth": depth,
"timeout": timeout, "timeout": timeout,
"created": datetime.datetime.utcnow() "created": datetime.datetime.utcnow(),
} }
self.locks[resource] = lock_dict self.locks[resource] = lock_dict
xml = await self.generate_lock_response(lock_dict) xml = await self.generate_lock_response(lock_dict)
headers = {"Lock-Token": f"<opaquelocktoken:{token}>"} headers = {"Lock-Token": f"<opaquelocktoken:{token}>"}
return aiohttp.web.Response(status=200, text=xml, content_type="application/xml", headers=headers) return aiohttp.web.Response(
status=200,
text=xml,
content_type="application/xml",
headers=headers,
)
except Exception as e: except Exception as e:
logging.error(f"LOCK error: {e}") logging.error(f"LOCK error: {e}")
return aiohttp.web.Response(status=400, text="Bad Request") return aiohttp.web.Response(status=400, text="Bad Request")
@ -669,14 +800,20 @@ class WebdavApplication(aiohttp.web.Application):
if self.is_lock_expired(lock): if self.is_lock_expired(lock):
del self.locks[resource] del self.locks[resource]
return aiohttp.web.Response(status=412, text="Precondition Failed") return aiohttp.web.Response(status=412, text="Precondition Failed")
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=409, text="Conflict") return aiohttp.web.Response(status=409, text="Conflict")
lock["created"] = datetime.datetime.utcnow() lock["created"] = datetime.datetime.utcnow()
lock["timeout"] = timeout lock["timeout"] = timeout
xml = await self.generate_lock_response(lock) xml = await self.generate_lock_response(lock)
headers = {"Lock-Token": f"<opaquelocktoken:{lock['token']}>"} headers = {"Lock-Token": f"<opaquelocktoken:{lock['token']}>"}
return aiohttp.web.Response(status=200, text=xml, content_type="application/xml", headers=headers) return aiohttp.web.Response(
status=200, text=xml, content_type="application/xml", headers=headers
)
async def handle_unlock(self, request): async def handle_unlock(self, request):
if not await self.authenticate(request): if not await self.authenticate(request):
@ -690,7 +827,11 @@ class WebdavApplication(aiohttp.web.Application):
if self.is_lock_expired(lock): if self.is_lock_expired(lock):
del self.locks[resource] del self.locks[resource]
return aiohttp.web.Response(status=409, text="Conflict") return aiohttp.web.Response(status=409, text="Conflict")
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=400, text="Invalid Lock Token") return aiohttp.web.Response(status=400, text="Invalid Lock Token")
del self.locks[resource] del self.locks[resource]
@ -705,14 +846,22 @@ class WebdavApplication(aiohttp.web.Application):
etree.SubElement(locktype, f"{{DAV:}}{lock_dict['type']}") etree.SubElement(locktype, f"{{DAV:}}{lock_dict['type']}")
lockscope = etree.SubElement(activelock, "{DAV:}lockscope") lockscope = etree.SubElement(activelock, "{DAV:}lockscope")
etree.SubElement(lockscope, f"{{DAV:}}{lock_dict['scope']}") etree.SubElement(lockscope, f"{{DAV:}}{lock_dict['scope']}")
etree.SubElement(activelock, "{DAV:}depth").text = lock_dict['depth'].capitalize() etree.SubElement(activelock, "{DAV:}depth").text = lock_dict[
if lock_dict['owner']: "depth"
owner = etree.fromstring(lock_dict['owner']) ].capitalize()
if lock_dict["owner"]:
owner = etree.fromstring(lock_dict["owner"])
activelock.append(owner) activelock.append(owner)
timeout_str = "Infinite" if lock_dict['timeout'] is None else f"Second-{lock_dict['timeout']}" timeout_str = (
"Infinite"
if lock_dict["timeout"] is None
else f"Second-{lock_dict['timeout']}"
)
etree.SubElement(activelock, "{DAV:}timeout").text = timeout_str etree.SubElement(activelock, "{DAV:}timeout").text = timeout_str
locktoken = etree.SubElement(activelock, "{DAV:}locktoken") locktoken = etree.SubElement(activelock, "{DAV:}locktoken")
etree.SubElement(locktoken, "{DAV:}href").text = f"opaquelocktoken:{lock_dict['token']}" etree.SubElement(locktoken, "{DAV:}href").text = (
f"opaquelocktoken:{lock_dict['token']}"
)
return etree.tostring(prop, encoding="utf-8", xml_declaration=True).decode() return etree.tostring(prop, encoding="utf-8", xml_declaration=True).decode()
def get_last_modified(self, path): def get_last_modified(self, path):
@ -731,7 +880,7 @@ class WebdavApplication(aiohttp.web.Application):
async def load_properties(self, resource_path): async def load_properties(self, resource_path):
props_file = self.get_props_file_path(resource_path) props_file = self.get_props_file_path(resource_path)
if props_file.exists(): if props_file.exists():
async with aiofiles.open(props_file, "r") as f: async with aiofiles.open(props_file) as f:
content = await f.read() content = await f.read()
return json.loads(content) return json.loads(content)
return {} return {}
@ -773,7 +922,11 @@ class WebdavApplication(aiohttp.web.Application):
response = etree.SubElement(response_xml, "{DAV:}response") response = etree.SubElement(response_xml, "{DAV:}response")
href = etree.SubElement(response, "{DAV:}href") href = etree.SubElement(response, "{DAV:}href")
relative_path = abs_path.relative_to(request["home"]).as_posix() relative_path = abs_path.relative_to(request["home"]).as_posix()
href_path = f"{self.relative_url}/{relative_path}".strip(".").replace("./", "/").replace("//", "/") href_path = (
f"{self.relative_url}/{relative_path}".strip(".")
.replace("./", "/")
.replace("//", "/")
)
href.text = href_path href.text = href_path
propstat = etree.SubElement(response, "{DAV:}propstat") propstat = etree.SubElement(response, "{DAV:}propstat")
prop = etree.SubElement(propstat, "{DAV:}prop") prop = etree.SubElement(propstat, "{DAV:}prop")
@ -787,8 +940,12 @@ class WebdavApplication(aiohttp.web.Application):
elem = etree.SubElement(prop, prop_name) elem = etree.SubElement(prop, prop_name)
elem.text = str(prop_value) elem.text = str(prop_value)
etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK" etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK"
xml_output = etree.tostring(response_xml, encoding="utf-8", xml_declaration=True).decode() xml_output = etree.tostring(
return aiohttp.web.Response(status=207, text=xml_output, content_type="application/xml") response_xml, encoding="utf-8", xml_declaration=True
).decode()
return aiohttp.web.Response(
status=207, text=xml_output, content_type="application/xml"
)
async def handle_propset(self, request): async def handle_propset(self, request):
if not await self.authenticate(request): if not await self.authenticate(request):
@ -801,7 +958,11 @@ class WebdavApplication(aiohttp.web.Application):
return aiohttp.web.Response(status=404, text="Resource not found") return aiohttp.web.Response(status=404, text="Resource not found")
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
body = await request.read() body = await request.read()
@ -824,17 +985,25 @@ class WebdavApplication(aiohttp.web.Application):
response = etree.SubElement(response_xml, "{DAV:}response") response = etree.SubElement(response_xml, "{DAV:}response")
href = etree.SubElement(response, "{DAV:}href") href = etree.SubElement(response, "{DAV:}href")
relative_path = abs_path.relative_to(request["home"]).as_posix() relative_path = abs_path.relative_to(request["home"]).as_posix()
href_path = f"{self.relative_url}/{relative_path}".strip(".").replace("./", "/").replace("//", "/") href_path = (
f"{self.relative_url}/{relative_path}".strip(".")
.replace("./", "/")
.replace("//", "/")
)
href.text = href_path href.text = href_path
propstat = etree.SubElement(response, "{DAV:}propstat") propstat = etree.SubElement(response, "{DAV:}propstat")
prop = etree.SubElement(propstat, "{DAV:}prop") prop = etree.SubElement(propstat, "{DAV:}prop")
for child in prop_elem: for child in prop_elem:
ns = child.nsmap.get(None, "DAV:") ns = child.nsmap.get(None, "DAV:")
prop_name = f"{{{ns}}}{child.tag.split('}')[-1]}" prop_name = f"{{{ns}}}{child.tag.split('}')[-1]}"
elem = etree.SubElement(prop, prop_name) etree.SubElement(prop, prop_name)
etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK" etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK"
xml_output = etree.tostring(response_xml, encoding="utf-8", xml_declaration=True).decode() xml_output = etree.tostring(
return aiohttp.web.Response(status=207, text=xml_output, content_type="application/xml") response_xml, encoding="utf-8", xml_declaration=True
).decode()
return aiohttp.web.Response(
status=207, text=xml_output, content_type="application/xml"
)
except Exception as e: except Exception as e:
logging.error(f"PROPSET error: {e}") logging.error(f"PROPSET error: {e}")
return aiohttp.web.Response(status=400, text="Bad Request") return aiohttp.web.Response(status=400, text="Bad Request")
@ -850,7 +1019,11 @@ class WebdavApplication(aiohttp.web.Application):
return aiohttp.web.Response(status=404, text="Resource not found") return aiohttp.web.Response(status=404, text="Resource not found")
lock = await self.is_locked(abs_path, request) lock = await self.is_locked(abs_path, request)
if lock: if lock:
submitted = request.headers.get("Lock-Token", "").strip(" <>").replace("opaquelocktoken:", "") submitted = (
request.headers.get("Lock-Token", "")
.strip(" <>")
.replace("opaquelocktoken:", "")
)
if submitted != lock["token"]: if submitted != lock["token"]:
return aiohttp.web.Response(status=423, text="Locked") return aiohttp.web.Response(status=423, text="Locked")
body = await request.read() body = await request.read()
@ -877,17 +1050,25 @@ class WebdavApplication(aiohttp.web.Application):
response = etree.SubElement(response_xml, "{DAV:}response") response = etree.SubElement(response_xml, "{DAV:}response")
href = etree.SubElement(response, "{DAV:}href") href = etree.SubElement(response, "{DAV:}href")
relative_path = abs_path.relative_to(request["home"]).as_posix() relative_path = abs_path.relative_to(request["home"]).as_posix()
href_path = f"{self.relative_url}/{relative_path}".strip(".").replace("./", "/").replace("//", "/") href_path = (
f"{self.relative_url}/{relative_path}".strip(".")
.replace("./", "/")
.replace("//", "/")
)
href.text = href_path href.text = href_path
propstat = etree.SubElement(response, "{DAV:}propstat") propstat = etree.SubElement(response, "{DAV:}propstat")
prop = etree.SubElement(propstat, "{DAV:}prop") prop = etree.SubElement(propstat, "{DAV:}prop")
for child in prop_elem: for child in prop_elem:
ns = child.nsmap.get(None, "DAV:") ns = child.nsmap.get(None, "DAV:")
prop_name = f"{{{ns}}}{child.tag.split('}')[-1]}" prop_name = f"{{{ns}}}{child.tag.split('}')[-1]}"
elem = etree.SubElement(prop, prop_name) etree.SubElement(prop, prop_name)
etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK" etree.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 200 OK"
xml_output = etree.tostring(response_xml, encoding="utf-8", xml_declaration=True).decode() xml_output = etree.tostring(
return aiohttp.web.Response(status=207, text=xml_output, content_type="application/xml") response_xml, encoding="utf-8", xml_declaration=True
).decode()
return aiohttp.web.Response(
status=207, text=xml_output, content_type="application/xml"
)
except Exception as e: except Exception as e:
logging.error(f"PROPDEL error: {e}") logging.error(f"PROPDEL error: {e}")
return aiohttp.web.Response(status=400, text="Bad Request") return aiohttp.web.Response(status=400, text="Bad Request")

View File

@ -1,10 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import pathlib
import sys import sys
import pathlib
import os
os.chdir("/root") os.chdir("/root")
@ -16,7 +14,9 @@ if not pathlib.Path(".welcome.txt").exists():
os.system("python3 -m venv --prompt '' .venv") os.system("python3 -m venv --prompt '' .venv")
os.system("cp -r /opt/bootstrap/root/.* /root") os.system("cp -r /opt/bootstrap/root/.* /root")
os.system("cp /opt/bootstrap/.welcome.txt /root/.welcome.txt") os.system("cp /opt/bootstrap/.welcome.txt /root/.welcome.txt")
pathlib.Path(".bashrc").write_text(pathlib.Path(".bashrc").read_text() + "\n" + "source .venv/bin/activate") pathlib.Path(".bashrc").write_text(
pathlib.Path(".bashrc").read_text() + "\n" + "source .venv/bin/activate"
)
os.environ["SNEK"] = "1" os.environ["SNEK"] = "1"
if pathlib.Path(".welcome.txt").exists(): if pathlib.Path(".welcome.txt").exists():