Compare commits

...

43 Commits
main ... main

Author SHA1 Message Date
af62d24868 chore: update css, js files 2026-01-03 18:03:13 +01:00
82ea496f0d chore: update py files 2026-01-03 15:06:47 +01:00
70544cf488 chore: update py files 2026-01-03 14:49:54 +01:00
1d0c835087 chore: update py files 2026-01-03 14:32:36 +01:00
8f803b86d9 chore: update css files 2026-01-03 14:28:43 +01:00
d5bff8b855 chore: update css, html, js files 2026-01-03 14:24:57 +01:00
f129574b62 chore: update html, js files 2026-01-03 14:13:55 +01:00
bfec6f9d69 chore: update css, js, py files 2026-01-03 14:04:12 +01:00
20543245f8 chore: update css, js files 2026-01-03 13:56:16 +01:00
38f027a9fb chore: update css files 2026-01-03 13:52:36 +01:00
1bc7bbe81e Update. 2026-01-03 13:42:23 +01:00
da30590080 Update. 2026-01-03 13:42:07 +01:00
a59bbc213a Update. 2025-12-31 10:57:51 +01:00
3689775efc Don't send empty dots and spaces 2025-12-31 10:37:15 +01:00
6250645fa4 Hardcoded tts and stt to english. 2025-12-31 10:32:49 +01:00
6099fc651c Update. 2025-12-31 10:31:07 +01:00
74074093dc Fixed STT 2025-12-31 09:59:24 +01:00
56d0ef01fa Socket upgrade. 2025-12-29 01:25:28 +01:00
163828e213 feat: add channel management dialogs and styling
feat: implement channel creation, settings, and deletion UI
feat: add RPC methods for channel operations
2025-12-26 02:05:06 +01:00
401e558011 perf: remove async locks from balancer, socket service, and cache
feat: add db connection injection for testing in app
build: update pytest configuration
2025-12-24 18:03:21 +01:00
572d9610f2 refactor: enhance socket service with granular locks and async optimizations
refactor: rename scheduled list to tasks in rpc view
2025-12-21 00:25:08 +01:00
1d3444d665 feat: add configurable registration openness 2025-12-19 12:53:58 +01:00
e0f54fb661 feat: disable open registration and require invitations 2025-12-19 12:34:02 +01:00
8bacd6aa3f feat: add debug logging option to serve command
refactor: reorganize imports and improve error handling in application
fix: correct typo in ip2location middleware
chore: add author headers to source files
2025-12-18 23:48:50 +01:00
b710008dbe refactor: enhance socket service robustness with locks and error handling
fix: add null checks and safe access utilities to prevent crashes
refactor: restructure socket methods for better concurrency and logging
2025-12-18 22:04:01 +01:00
70a405b231 fix: ensure socket cleanup in websocket handler 2025-12-17 22:54:25 +01:00
bdc8f149dc refactor: remove presence debounce for instant user departures
refactor: simplify websocket connection and error handling in rpc view
2025-12-17 22:47:21 +01:00
2e886c7bc1 chore: remove umami analytics script 2025-12-17 22:18:42 +01:00
1eed22de5c chore: update py files 2025-12-17 22:09:54 +01:00
c23ce6085a feat: add user presence notifications with debounced departures 2025-12-17 21:54:07 +01:00
e798fd50a8 chore: remove umami analytics integration 2025-12-17 18:34:32 +01:00
48b360bce1 fix: replace safe dictionary access with direct access and add null check for starField 2025-12-17 18:24:08 +01:00
0e083268c6 Fixed a few bugs. 2025-12-15 00:10:50 +01:00
0ad8f6cb31 Update. 2025-12-05 19:17:09 +01:00
f8d650567a Update. 2025-12-05 18:16:04 +01:00
8db6f39046 Update. 2025-12-05 18:15:36 +01:00
e4ebd8b4fd fix: correct syntax errors and remove vulnerable code in WebSocket classes
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:13:36 +01:00
efcd10c3c0 docs: update project quality review with full codebase analysis
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:12:06 +01:00
2a9b883e1b docs: add project quality review to review.md
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:05:42 +01:00
f770dcf2db Update. 2025-11-03 18:08:13 +01:00
2deb8a2069 Update. 2025-10-28 20:50:53 +01:00
87e19e3d02 Merge pull request 'Improved paste handling to support selection and providing undo support' (#74) from BordedDev/snek:feature/even-better-paste-handling into main
Reviewed-on: retoor/snek#74
2025-10-05 22:07:18 +02:00
BordedDev
31a754d264 Improved paste handling to support selection and providing undo support 2025-10-05 21:03:35 +02:00
204 changed files with 8029 additions and 2460 deletions

194
CHANGELOG.md Normal file
View File

@ -0,0 +1,194 @@
# Changelog
## Version 1.24.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 60 lines
**Languages:** CSS (44 lines), JavaScript (16 lines)
## Version 1.23.0 - 2026-01-03
update py files
**Changes:** 1 files, 30 lines
**Languages:** Python (30 lines)
## Version 1.22.0 - 2026-01-03
update py files
**Changes:** 1 files, 8 lines
**Languages:** Python (8 lines)
## Version 1.21.0 - 2026-01-03
update py files
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.20.0 - 2026-01-03
update css files
**Changes:** 2 files, 25 lines
**Languages:** CSS (25 lines)
## Version 1.19.0 - 2026-01-03
update css, html, js files
**Changes:** 4 files, 311 lines
**Languages:** CSS (137 lines), HTML (3 lines), JavaScript (171 lines)
## Version 1.18.0 - 2026-01-03
update html, js files
**Changes:** 2 files, 271 lines
**Languages:** HTML (39 lines), JavaScript (232 lines)
## Version 1.17.0 - 2026-01-03
update css, js, py files
**Changes:** 4 files, 17 lines
**Languages:** CSS (11 lines), JavaScript (2 lines), Python (4 lines)
## Version 1.16.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 15 lines
**Languages:** CSS (11 lines), JavaScript (4 lines)
## Version 1.15.0 - 2026-01-03
update css files
**Changes:** 1 files, 19 lines
**Languages:** CSS (19 lines)
## Version 1.14.0 - 2025-12-26
Users can now create, configure settings for, and delete channels through dedicated dialog interfaces. Developers access new RPC methods to support these channel management operations.
**Changes:** 9 files, 801 lines
**Languages:** CSS (127 lines), HTML (517 lines), Python (157 lines)
## Version 1.13.0 - 2025-12-24
Improves performance in the balancer, socket service, and cache by removing async locks. Adds database connection injection for testing in the app and updates pytest configuration.
**Changes:** 5 files, 291 lines
**Languages:** Other (6 lines), Python (285 lines)
## Version 1.12.0 - 2025-12-21
The socket service enhances concurrency and performance through improved locking and asynchronous handling. The RPC view renames the scheduled list to tasks for clearer API terminology.
**Changes:** 2 files, 243 lines
**Languages:** Python (243 lines)
## Version 1.11.0 - 2025-12-19
Adds the ability to configure whether user registration is open or closed via system settings. Administrators can now toggle registration openness to control access to the registration form.
**Changes:** 5 files, 262 lines
**Languages:** HTML (185 lines), Python (77 lines)
## Version 1.10.0 - 2025-12-19
Users must now receive an invitation to register for an account, as open registration is disabled.
**Changes:** 1 files, 6 lines
**Languages:** HTML (6 lines)
## Version 1.9.0 - 2025-12-18
Adds a debug logging option to the serve command for enhanced troubleshooting. Improves error handling across the application and corrects a typo in the ip2location middleware.
**Changes:** 148 files, 1047 lines
**Languages:** JavaScript (299 lines), Python (748 lines)
## Version 1.8.0 - 2025-12-18
The socket service now handles errors more robustly and prevents crashes through improved safety checks. Socket methods support better concurrency and provide enhanced logging for developers.
**Changes:** 4 files, 2279 lines
**Languages:** JavaScript (592 lines), Python (1687 lines)
## Version 1.7.0 - 2025-12-17
Fixes socket cleanup in the websocket handler to prevent resource leaks and improve connection stability.
**Changes:** 1 files, 29 lines
**Languages:** Python (29 lines)
## Version 1.6.0 - 2025-12-17
Removes presence debounce to make user departures instant. Simplifies websocket connection and error handling in RPC views for improved reliability.
**Changes:** 2 files, 98 lines
**Languages:** Python (98 lines)
## Version 1.5.0 - 2025-12-17
remove umami analytics script
**Changes:** 1 files, 1 lines
**Languages:** HTML (1 lines)
## Version 1.4.0 - 2025-12-17
Updates the socket service to improve connection stability and error handling.
**Changes:** 1 files, 4 lines
**Languages:** Python (4 lines)
## Version 1.3.0 - 2025-12-17
Users now receive notifications when other users join or depart the application. Departure notifications are debounced to reduce the frequency of rapid successive alerts.
**Changes:** 5 files, 418 lines
**Languages:** HTML (1 lines), JavaScript (259 lines), Python (158 lines)
## Version 1.2.0 - 2025-12-17
Removes Umami analytics integration, eliminating user tracking functionality. Developers must handle analytics separately if needed.
**Changes:** 2 files, 2 lines
**Languages:** HTML (1 lines), Python (1 lines)
## Version 1.1.0 - 2025-12-17
Fixes potential errors in forum message handling by adding a null check for the star field, preventing crashes when the field is missing. Updates the message list display to handle starred messages more reliably.
**Changes:** 3 files, 10 lines
**Languages:** JavaScript (2 lines), Python (8 lines)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.0.0"
version = "1.24.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
@ -40,7 +40,8 @@ dependencies = [
"pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk"
"sentry-sdk",
"bcrypt"
]
[tool.setuptools.packages.find]
@ -51,3 +52,10 @@ where = ["src"] # <-- this changed
[project.scripts]
snek = "snek.__main__:main"
[project.optional-dependencies]
test = [
"pytest",
"pytest-asyncio",
"pytest-aiohttp"
]

15
pytest.ini Normal file
View File

@ -0,0 +1,15 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--strict-markers
--disable-warnings
--tb=short
-v
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
asyncio_mode = auto

55
review.md Normal file
View File

@ -0,0 +1,55 @@
# Project Quality Review: Snek
## Overview
The Snek project is a comprehensive web-based application, functioning as a collaborative platform or chat system with extensive features including user management, channels, repositories, containers, real-time communication via WebSockets, Docker integration, Git support, and more. It leverages Python (primarily with aiohttp for the backend) and JavaScript for the frontend. The codebase is organized into modules such as app, mapper, model, service, view, system, and static assets. This review is based on a literal examination of every Python file in `src/snek/*` recursively, as their full contents have been provided.
## Strengths
- **Modular Architecture**: The code exhibits strong separation of concerns with dedicated modules for mappers (data access), models (data structures), services (business logic), views (HTTP/WebSocket handlers), and system utilities. This promotes maintainability and scalability.
- **Asynchronous Support**: Extensive use of async/await throughout (e.g., in `src/snek/app.py`, `src/snek/service/socket.py`, `src/snek/view/rpc.py`), effectively handling I/O-bound operations like WebSockets, database queries, and external API calls.
- **Feature-Rich**: Supports a wide array of features, including user authentication, file uploads, terminal emulation, Docker container management, Git repositories, WebDAV, SSH, forums, push notifications, and avatar generation. Integration with external tools like Docker and Git is notable.
- **Frontend Components**: Custom JavaScript components (e.g., in `src/snek/static/njet.js`) provide a modern, component-based UI with event handling and REST clients.
- **Caching and Utilities**: Robust caching implementation (e.g., in `src/snek/system/cache.py`) with LRU eviction, and utility services (e.g., `src/snek/service/util.py`) enhance performance and reusability.
- **Model and Field System**: A sophisticated model system (e.g., in `src/snek/system/model.py`) with typed fields (e.g., UUIDField, CreatedField) ensures data integrity and validation.
- **Extensibility**: The codebase includes hooks for extensions (e.g., Jinja2 extensions in `src/snek/system/markdown.py`, `src/snek/system/template.py`) and sub-applications (e.g., forum in `src/snek/forum.py`).
- **Error Handling in Places**: Some areas show good error handling (e.g., try-except in `src/snek/service/socket.py`, `src/snek/view/rpc.py`), though not universal.
## Weaknesses
- **Syntax Errors**:
- In `src/snek/research/serpentarium.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` is misspelled; it should be `setattr`. Additionally, the line ends with `)`, which is misplaced and causes a syntax error. Line 26: `self.setattr(self, "db", self.set)` – similar misspelling and incorrect assignment (self.set is not defined).
- In `src/snek/sync.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` misspelled and misplaced `)`. Line 26: `self.setattr(self, "db", self.set)` – same issues. Line 27: `super()` – called without arguments, which may fail in classes with multiple inheritance or if arguments are expected.
- **Security Concerns**:
- **Injection Vulnerabilities**: Raw SQL queries without parameterization (e.g., in `src/snek/service/channel.py`, `src/snek/service/user.py`, `src/snek/view/rpc.py`) risk SQL injection. For example, in `src/snek/service/channel.py`, queries like `f"SELECT ... WHERE channel_uid=:channel_uid {history_start_filter}"` use string formatting, but not all are parameterized.
- **Authentication Gaps**: Some WebSocket and RPC endpoints lack visible authentication checks (e.g., in `src/snek/view/rpc.py`, methods like `echo` and `query` don't enforce login). Basic auth in `src/snek/webdav.py` and `src/snek/sssh.py` is present but hardcoded.
- **Input Validation**: Minimal input sanitization (e.g., in `src/snek/system/form.py`, forms have placeholders but no regex or length checks enforced). User inputs in RPC calls (e.g., `src/snek/view/rpc.py`) are not validated.
- **Hardcoded Secrets**: Database paths (e.g., 'sqlite:///snek.db' in multiple files), keys (e.g., SESSION_KEY in `src/snek/app.py`), and credentials are hardcoded, posing risks if exposed.
- **Privilege Escalation**: Admin checks are inconsistent (e.g., in `src/snek/service/channel.py`, `src/snek/view/rpc.py`), and some operations (e.g., clearing channels) only check `is_admin` without further validation.
- **WebDAV and SSH**: `src/snek/webdav.py` and `src/snek/sssh.py` handle file access but lack rate limiting or detailed permission checks.
- **Code Quality Issues**:
- **Inconsistent Naming and Style**: Mixed camelCase and snake_case (e.g., `set_typing` vs. `generateUniqueId` in JS). Some classes have similar names (e.g., `DatasetWebSocketView` in research files).
- **Lack of Documentation**: Few docstrings or comments (e.g., no docstrings in `src/snek/service/user.py`, `src/snek/view/channel.py`). Methods are often self-explanatory but lack context.
- **Hardcoded Values**: URLs, paths, and constants are hardcoded (e.g., in `src/snek/static/njet.js`, `src/snek/app.py`).
- **Global State and Side Effects**: Use of global variables (e.g., in `src/snek/system/markdown.py`, `src/snek/view/rpc.py`) and mutable defaults can lead to bugs.
- **Performance Issues**: No visible optimization for large datasets (e.g., in `src/snek/service/channel_message.py`, queries could be inefficient). Process pools in `src/snek/service/channel_message.py` are per-message, potentially wasteful.
- **Error Handling Gaps**: Many methods lack try-except (e.g., in `src/snek/system/docker.py`, `src/snek/system/terminal.py`). Exceptions are sometimes caught but not logged properly.
- **Dependencies**: Imports like `dataset`, `git`, `pymongo` (implied) are not version-pinned, risking compatibility issues.
- **Testing Absence**: No visible unit or integration tests in the codebase.
- **Potential Bugs**:
- In `src/snek/research/serpentarium.py`, `self.setattr(self, "db", self.set)` assigns undefined `self.set`.
- In `src/snek/sync.py`, similar assignment issues.
- In `src/snek/view/rpc.py`, `self.user_uid` property assumes `self.view.session.get("uid")`, but no null checks.
- In `src/snek/system/docker.py`, `ComposeFileManager` uses subprocess without full error handling.
- In `src/snek/service/channel_message.py`, executor pools per UID could lead to resource leaks if not cleaned up.
- In `src/snek/forum.py`, event listeners are added but no removal logic is visible.
- **Maintainability**: Large files (e.g., `src/snek/view/rpc.py` is over 1000 lines) and complex methods (e.g., in `src/snek/app.py`) make refactoring hard. Some code duplication (e.g., WebSocket handling in multiple views).
## Recommendations
- **Fix Syntax Errors Immediately**: Correct `setattr` spellings, remove misplaced `)`, and fix `super()` calls in `src/snek/research/serpentarium.py` and `src/snek/sync.py` to prevent runtime failures.
- **Enhance Security**: Implement parameterized queries, add input validation (e.g., using regex in `src/snek/system/form.py`), enforce authentication in all endpoints, and use environment variables for secrets.
- **Improve Code Quality**: Add docstrings, comments, and consistent naming. Refactor large methods and remove hardcoded values.
- **Add Error Handling and Testing**: Wrap risky operations in try-except, log errors, and introduce unit tests (e.g., using pytest).
- **Optimize Performance**: Review query efficiency, add indexing, and manage resources (e.g., executor pools).
- **Address Bugs**: Fix undefined assignments and add null checks.
- **General**: Pin dependencies, review for race conditions in async code, and consider code reviews or linters (e.g., flake8, mypy).
## Grade
B- (Solid foundation with good architecture and features, but critical syntax errors, security vulnerabilities, and quality issues require immediate attention to avoid production risks.)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
"""
MIT License

View File

@ -1,18 +1,18 @@
import logging
logging.basicConfig(level=logging.INFO)
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import shutil
import sqlite3
import asyncio
import click
from aiohttp import web
from IPython import start_ipython
from snek.shell import Shell
from snek.app import Application
from snek.shell import Shell
logging.basicConfig(level=logging.INFO)
@click.group()
@ -111,9 +111,16 @@ def init(db_path, source):
show_default=True,
help="Database path for the application",
)
def serve(port, host, db_path):
# init(db_path)
# asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
@click.option(
"--debug",
is_flag=True,
default=False,
help="Enable debug logging",
)
def serve(port, host, db_path, debug):
if debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("snek").setLevel(logging.DEBUG)
web.run_app(Application(db_path=f"sqlite:///{db_path}"), port=port, host=host)

View File

@ -1,22 +1,15 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import ssl
import uuid
import signal
from datetime import datetime
from contextlib import asynccontextmanager
import aiohttp_debugtoolbar
from snek import snode
from snek.view.threads import ThreadsView
logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from datetime import datetime
from ipaddress import ip_address
import IP2Location
from aiohttp import web
@ -29,6 +22,7 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
from snek import snode
from snek.mapper import get_mappers
from snek.service import get_services
from snek.sgit import GitApplication
@ -37,6 +31,7 @@ from snek.system import http
from snek.system.cache import Cache
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.config import config
from snek.system.profiler import profiler_handler
from snek.system.template import (
EmojiExtension,
@ -51,6 +46,7 @@ from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView,
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveApiView, DriveView
from snek.view.channel import ChannelDriveApiView
from snek.view.container import ContainerView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.logout import LogoutView
@ -59,7 +55,7 @@ from snek.view.register import RegisterView
from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView
from snek.view.container import ContainerView
from snek.view.threads import ThreadsView
from snek.view.settings.containers import (
ContainersCreateView,
ContainersDeleteView,
@ -74,6 +70,13 @@ from snek.view.settings.repositories import (
RepositoriesIndexView,
RepositoriesUpdateView,
)
from snek.view.settings.profile_pages import (
ProfilePagesView,
ProfilePageCreateView,
ProfilePageEditView,
ProfilePageDeleteView,
)
from snek.view.profile_page import ProfilePageView
from snek.view.stats import StatsView
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
@ -82,9 +85,12 @@ from snek.view.user import UserView
from snek.view.web import WebView
from snek.webdav import WebdavApplication
from snek.forum import setup_forum
from snek.system.template import whitelist_attributes
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware
@ -97,21 +103,22 @@ async def session_middleware(request, handler):
@web.middleware
async def ip2location_middleware(request, handler):
response = await handler(request)
return response
ip = request.headers.get("X-Forwarded-For", request.remote)
ipaddress = ip_address(ip)
if ipaddress.is_private:
try:
ipaddr = ip_address(ip)
if ipaddr.is_private:
return response
except ValueError:
return response
if not request.app.session.get("uid"):
if not request.session.get("uid"):
return response
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
user = await request.app.services.user.get(uid=request.session.get("uid"))
if not user:
return response
location = request.app.ip2location.get(ip)
user["city"]
location = request.app.ip2location.get_all(ip)
if user["city"] != location.city:
user["country_long"] = location.country
user["country_short"] = locaion.country_short
user["country_long"] = location.country_long
user["country_short"] = location.country_short
user["city"] = location.city
user["region"] = location.region
user["latitude"] = location.latitude
@ -124,23 +131,35 @@ async def ip2location_middleware(request, handler):
@web.middleware
async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"):
# Redirect to the same path with a trailing slash
raise web.HTTPFound(request.path + "/")
return await handler(request)
class Application(BaseApplication):
def __init__(self, *args, **kwargs):
async def create_default_forum(self, app):
forums = [f async for f in self.services.forum.find(is_active=True)]
if not forums:
# Find admin user to be the creator
admin_user = await self.services.user.get(is_admin=True)
if admin_user:
await self.services.forum.create_forum(
name="General Discussion",
description="A place for general discussion.",
created_by_uid=admin_user["uid"],
)
def __init__(self, *args, db_connection=None, **kwargs):
middlewares = [
cors_middleware,
csp_middleware,
]
self._test_db = db_connection
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5 * args,
client_max_size=1024 * 1024 * 1024 * 5,
**kwargs,
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
@ -161,11 +180,12 @@ class Application(BaseApplication):
self.sync_service = None
self.executor = None
self.cache = Cache(self)
self.config = config
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
@ -175,7 +195,17 @@ class Application(BaseApplication):
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
self.on_startup.append(self.create_default_forum)
@property
def db(self):
if self._test_db is not None:
return self._test_db
return self._db
@db.setter
def db(self, value):
self._db = value
@property
def uptime_seconds(self):
@ -217,13 +247,8 @@ class Application(BaseApplication):
asyncio.create_task(app.ssh_server.wait_closed())
async def prepare_asyncio(self, app):
# app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
#for sig in (signal.SIGINT, signal.SIGTERM):
#app.loop.add_signal_handler(
# sig, lambda: asyncio.create_task(self.services.container.shutdown())
#)
async def create_task(self, task):
await self.tasks.put(task)
@ -236,9 +261,8 @@ class Application(BaseApplication):
await task
self.tasks.task_done()
except Exception as ex:
print(ex)
logger.error(f"Task runner error: {ex}")
self.db.commit()
async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL")
@ -251,8 +275,8 @@ class Application(BaseApplication):
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
except:
pass
except Exception as ex:
logger.warning(f"Index creation error: {ex}")
await self.services.drive.prepare_all()
self.loop.create_task(self.task_runner())
@ -285,9 +309,6 @@ class Application(BaseApplication):
self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView)
# self.router.add_view("/drive/{rel_path:.*}", DriveView)
## self.router.add_view("/drive.bin", UploadView)
# self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView)
@ -295,27 +316,22 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
#self.router.add_view(
# "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
#)
#self.router.add_view(
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView
#)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
)
self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)#
)
self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView)
#self.router.add_view("/drive.json", DriveApiView)
#self.router.add_view("/drive.html", DriveView)
#self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/user/{user_uid}/{slug}.html", ProfilePageView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view(
"/repository/{username}/{repository}/{path:.*}", RepositoryView
@ -332,6 +348,14 @@ class Application(BaseApplication):
"/settings/repositories/repository/{name}/delete.html",
RepositoriesDeleteView,
)
self.router.add_view("/settings/profile_pages/index.html", ProfilePagesView)
self.router.add_view("/settings/profile_pages/create.html", ProfilePageCreateView)
self.router.add_view(
"/settings/profile_pages/{page_uid}/edit.html", ProfilePageEditView
)
self.router.add_view(
"/settings/profile_pages/{page_uid}/delete.html", ProfilePageDeleteView
)
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view(
@ -346,10 +370,8 @@ class Application(BaseApplication):
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
)
@ -366,14 +388,13 @@ class Application(BaseApplication):
body=path.read_bytes(), headers={"Content-Type": "image/png"}
)
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
start_time = time.perf_counter()
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
context["config"] = self.config
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
@ -411,31 +432,25 @@ class Application(BaseApplication):
request.session.get("uid")
)
self.template_path.joinpath(template)
await self.services.user.get_template_path(request.session.get("uid"))
self.original_loader = self.jinja2_env.loader
self.jinja2_env.loader = await self.get_user_template_loader(
request.session.get("uid")
)
try:
context["nonce"] = request['csp_nonce']
except:
context['nonce'] = '?'
context["nonce"] = request["csp_nonce"]
except KeyError:
context["nonce"] = "?"
rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader
end_time = time.perf_counter()
print(f"render_template took {end_time - start_time:.4f} seconds")
# rendered.text = whitelist_attributes(rendered.text)
# rendered.headers['Content-Lenght'] = len(rendered.text)
logger.debug(f"render_template took {end_time - start_time:.4f} seconds")
return rendered
async def static_handler(self, request):
file_name = request.match_info.get("filename", "")
@ -473,22 +488,21 @@ class Application(BaseApplication):
template_paths.append(self.template_path)
return FileSystemLoader(template_paths)
@asynccontextmanager
async def no_save(self):
stats = {
'count': 0
}
stats = {"count": 0}
async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0])
stats['count'] = stats['count'] + 1
print(f"save is ignored {stats['count']} times")
stats["count"] = stats["count"] + 1
logger.debug(f"save is ignored {stats['count']} times")
return args[0]
save_original = self.services.channel_message.mapper.save
self.services.channel_message.mapper.save = patched_save
raised_exception = None
try:
yield
yield
except Exception as ex:
raised_exception = ex
finally:
@ -497,7 +511,6 @@ class Application(BaseApplication):
raise raised_exception
app = Application(db_path="sqlite:///snek.db")
#aiohttp_debugtoolbar.setup(app)
async def main():

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio
import sys
@ -7,7 +9,6 @@ class LoadBalancer:
self.backend_ports = backend_ports
self.backend_processes = []
self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock()
async def start_backend_servers(self, port, workers):
for x in range(workers):
@ -27,10 +28,9 @@ class LoadBalancer:
)
async def handle_client(self, reader, writer):
async with self.lock:
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ("127.0.0.1", self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
@ -55,8 +55,7 @@ class LoadBalancer:
print(f"Error: {e}")
finally:
writer.close()
async with self.lock:
self.client_counts[server_index] -= 1
self.client_counts[server_index] -= 1
async def monitor(self):
while True:

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import pathlib
from aiohttp import web

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio
from snek.app import app

View File

@ -0,0 +1,3 @@
# retoor <retoor@molodetz.nl>

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
@ -78,29 +80,7 @@ class ForumApplication(aiohttp.web.Application):
async def serve_forum_html(self, request):
"""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 aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.app import app
application = app

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.mapper.channel import ChannelMapper
@ -12,6 +14,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.profile_page import ProfilePageMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object
@ -36,6 +39,7 @@ def get_mappers(app=None):
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
"profile_page": ProfilePageMapper(app=app),
}
)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel import ChannelModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.container import Container
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive import DriveModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper

View File

@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.profile_page import ProfilePageModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class ProfilePageMapper(BaseMapper):
table_name = "profile_page"
model_class = ProfilePageModel

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.repository import RepositoryModel
from snek.system.mapper import BaseMapper

View File

@ -1,6 +1,12 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.user import UserModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class UserMapper(BaseMapper):
table_name = "user"
@ -16,5 +22,5 @@ class UserMapper(BaseMapper):
)
]
except Exception as ex:
print(ex)
logger.warning(f"Failed to get admin uids: {ex}")
return []

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.user_property import UserPropertyModel
from snek.system.mapper import BaseMapper

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.model.channel import ChannelModel
@ -44,5 +46,3 @@ def get_models():
def get_model(name):
return get_models()[name]

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
@ -26,9 +28,8 @@ class ChannelModel(BaseModel):
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
{"channel_uid": self["uid"]},
):
return await self.app.services.channel_message.get(uid=model["uid"])
except:
except Exception:
pass
return None

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from snek.model.user import UserModel

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
# models/forum.py
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import BaseModel, ModelField
logger = logging.getLogger(__name__)
class ProfilePageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str)
slug = ModelField(name="slug", required=True, kind=str)
content = ModelField(name="content", required=False, kind=str, value="")
order_index = ModelField(name="order_index", required=True, kind=int, value=0)
is_published = ModelField(name="is_published", required=True, kind=bool, value=True)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@ -7,4 +9,6 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
description = ModelField(name="description", required=False, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField

View File

@ -1,7 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
value = ModelField(name="path", required=True, kind=str)
value = ModelField(name="value", required=True, kind=str)

View File

@ -1,39 +1,15 @@
# retoor <retoor@molodetz.nl>
import json
import asyncio
import aiohttp
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
import json
class DatasetMethod:
def __init__(self, dt, name):
self.dt = dt
self.name = name
def __call__(self, *args, **kwargs):
return self.dt.ds.call(
self.dt.name,
self.name,
*args,
**kwargs
)
pass
class DatasetTable:
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
pass
class WebSocketClient2:
@ -43,200 +19,40 @@ class WebSocketClient2:
self.websocket = None
self.receive_queue = asyncio.Queue()
# Schedule connection setup
if self.loop.is_running():
# Schedule connect in the existing loop
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
else:
# If loop isn't running, connect synchronously
self.loop.run_until_complete(self._connect())
async def _connect(self):
self.websocket = await websockets.connect(self.uri)
# Start listening for messages
asyncio.create_task(self._receive_loop())
async def _receive_loop(self):
try:
async for message in self.websocket:
await self.receive_queue.put(message)
except Exception:
pass # Handle exceptions as needed
def send(self, message: str):
if self.loop.is_running():
# Schedule send in the existing loop
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
else:
# If loop isn't running, run directly
self.loop.run_until_complete(self.websocket.send(message))
def receive(self):
# Wait for a message synchronously
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
return future.result()
pass
def close(self):
if self.websocket:
if self.loop.is_running():
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
else:
self.loop.run_until_complete(self.websocket.close())
pass
import websockets
class DatasetWrapper(object):
class DatasetWrapper:
def __init__(self):
self.ws = WebSocketClient()
def begin(self):
self.call(None, 'begin')
pass
def commit(self):
self.call(None, 'commit')
def __getitem__(self, name):
return DatasetTable(self, name)
pass
def query(self, *args, **kwargs):
return self.call(None, 'query', *args, **kwargs)
def call(self, table, method, *args, **kwargs):
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
#if method in ['find','find_one']:
payload["call_uid"] = str(uuid.uuid4())
self.ws.write(json.dumps(payload))
if payload["call_uid"]:
response = self.ws.read()
return json.loads(response)['result']
return True
pass
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
pass
async def send_str(self, msg):
return await self.ws.send_str(msg)
pass
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
pass
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
pass
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
app = web.Application()
view = DatasetWebSocketView()
app.router.add_get('/db', view.handle)
async def run_server():
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', 3131)
await site.start()
print("Server started at http://localhost:8080")
await asyncio.Event().wait()
async def client():
print("x")
d = DatasetWrapper()
print("y")
for x in range(100):
for x in range(100):
if d['test'].insert({"name": "test", "number":x}):
print(".",end="",flush=True)
print("")
print(d['test'].find_one(name="test", order_by="-number"))
print("DONE")
import time
async def main():
await run_server()
import sys
if __name__ == '__main__':
if sys.argv[1] == 'server':
asyncio.run(main())
if sys.argv[1] == 'client':
asyncio.run(client())
pass

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import time
from concurrent.futures import ProcessPoolExecutor

View File

@ -1,32 +1,33 @@
CREATE TABLE IF NOT EXISTS http_access (
id INTEGER NOT NULL,
created TEXT,
path TEXT,
duration FLOAT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user (
CREATE TABLE user (
id INTEGER NOT NULL,
city TEXT,
color TEXT,
country_long TEXT,
country_short TEXT,
created_at TEXT,
deleted_at TEXT,
email TEXT,
ip TEXT,
is_admin TEXT,
last_ping TEXT,
latitude TEXT,
longitude TEXT,
nick TEXT,
password TEXT,
region TEXT,
uid TEXT,
updated_at TEXT,
username TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS channel (
CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE channel (
id INTEGER NOT NULL,
created_at TEXT,
created_by_uid TEXT,
deleted_at TEXT,
description TEXT,
history_start TEXT,
"index" BIGINT,
is_listed BOOLEAN,
is_private BOOLEAN,
@ -37,8 +38,8 @@ CREATE TABLE IF NOT EXISTS channel (
updated_at TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
@ -54,28 +55,31 @@ CREATE TABLE IF NOT EXISTS channel_member (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE IF NOT EXISTS broadcast (
id INTEGER NOT NULL,
channel_uid TEXT,
message TEXT,
created_at TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channel_message (
CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
html TEXT,
is_final BOOLEAN,
message TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification (
CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE INDEX ix_channel_message_acb69f257bb37684 ON channel_message (is_final, user_uid, channel_uid);
CREATE INDEX ix_channel_message_c6c4cddf281df93d ON channel_message (deleted_at);
CREATE TABLE kv (
id INTEGER NOT NULL,
"key" TEXT,
value TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_kv_a62f2225bf70bfac ON kv ("key");
CREATE TABLE notification (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
@ -88,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository (
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
CREATE TABLE drive (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
name TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_drive_e2577dd78b54fe28 ON drive (uid);
CREATE TABLE push_registration (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
endpoint TEXT,
key_auth TEXT,
key_p256dh TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_push_registration_e2577dd78b54fe28 ON push_registration (uid);
CREATE TABLE repository (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
description TEXT,
is_private BIGINT,
name TEXT,
uid TEXT,
@ -100,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
CREATE TABLE profile_page (
id INTEGER NOT NULL,
content TEXT,
created_at TEXT,
deleted_at TEXT,
is_published BOOLEAN,
order_index BIGINT,
slug TEXT,
title TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.service.channel import ChannelService
@ -19,6 +21,7 @@ from snek.service.util import UtilService
from snek.system.object import Object
from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
from snek.service.profile_page import ProfilePageService
_service_registry = {}
def register_service(name, service_cls):
@ -40,7 +43,7 @@ def get_services(app):
def get_service(name, app=None):
return get_services(app=app)[name]
# Registering all services
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
@ -62,4 +65,5 @@ register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import pathlib
from datetime import datetime
@ -13,7 +15,7 @@ class ChannelService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
except OSError:
pass
return folder

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.service import BaseService

View File

@ -1,3 +1,6 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import now
from snek.system.service import BaseService
@ -8,11 +11,12 @@ class ChannelMemberService(BaseService):
async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member)
async def get_user_uids(self, channel_uid):
async for model in self.mapper.query(
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid AND deleted_at IS NULL AND is_banned = 0",
{"channel_uid": channel_uid},
):
yield model["user_uid"]
@ -56,7 +60,6 @@ class ChannelMemberService(BaseService):
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
async def get_other_dm_user(self, channel_uid, user_uid):

View File

@ -1,18 +1,21 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import pathlib
from concurrent.futures import ProcessPoolExecutor
from snek.system.service import BaseService
from snek.system.template import sanitize_html
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader
global jinja2_env
import pathlib
logger = logging.getLogger(__name__)
jinja2_env = None
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template =jinja2_env.get_template("message.html")
template = jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
@ -27,10 +30,11 @@ class ChannelMessageService(BaseService):
global jinja2_env
jinja2_env = self.app.jinja2_env
self._max_workers = 1
def get_or_create_executor(self, uid):
if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=self._max_workers)
print("Executors available", len(self._executor_pools))
logger.debug(f"Executors available: {len(self._executor_pools)}")
return self._executor_pools[uid]
def delete_executor(self, uid):
@ -39,34 +43,6 @@ class ChannelMessageService(BaseService):
del self._executor_pools[uid]
async def maintenance(self):
args = {}
return
for message in self.mapper.db["channel_message"].find():
print(message)
try:
message = await self.get(uid=message["uid"])
updated_at = message["updated_at"]
message["is_final"] = True
html = message["html"]
await self.save(message)
self.mapper.db["channel_message"].upsert(
{
"uid": message["uid"],
"updated_at": updated_at,
},
["uid"],
)
if html != message["html"]:
print("Reredefined message", message["uid"])
except Exception as ex:
time.sleep(0.1)
print(ex, flush=True)
while True:
changed = 0
async for message in self.find(is_final=False):
@ -81,6 +57,7 @@ class ChannelMessageService(BaseService):
break
async def create(self, channel_uid, user_uid, message, is_final=True):
logger.info(f"create: channel_uid={channel_uid}, user_uid={user_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
model = await self.new()
model["channel_uid"] = channel_uid
@ -93,6 +70,9 @@ class ChannelMessageService(BaseService):
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.error(f"create: user not found user_uid={user_uid}")
raise Exception("User not found")
context.update(
{
"user_uid": user["uid"],
@ -103,15 +83,13 @@ class ChannelMessageService(BaseService):
)
loop = asyncio.get_event_loop()
try:
context = json.loads(json.dumps(context, default=str))
context = json.loads(json.dumps(context, default=str))
logger.debug(f"create: rendering html for message uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
except Exception as ex:
print(ex, flush=True)
logger.error(f"create: html rendering failed: {ex}")
logger.debug(f"create: saving message uid={model['uid']}")
if await super().save(model):
if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index(
@ -129,7 +107,9 @@ class ChannelMessageService(BaseService):
self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
logger.info(f"create: message created successfully uid={model['uid']}, channel={channel_uid}")
return model
logger.error(f"create: failed to save message channel={channel_uid}, errors={model.errors}")
raise Exception(f"Failed to create channel message: {model.errors}.")
async def to_extended_dict(self, message):
@ -154,9 +134,13 @@ class ChannelMessageService(BaseService):
}
async def save(self, model):
logger.debug(f"save: starting for uid={model['uid']}, is_final={model['is_final']}")
context = {}
context.update(model.record)
user = await self.app.services.user.get(model["user_uid"])
if not user:
logger.error(f"save: user not found user_uid={model['user_uid']}")
return False
context.update(
{
"user_uid": user["uid"],
@ -165,12 +149,16 @@ class ChannelMessageService(BaseService):
"color": user["color"],
}
)
context = json.loads(json.dumps(context, default=str))
context = json.loads(json.dumps(context, default=str))
loop = asyncio.get_event_loop()
logger.debug(f"save: rendering html for uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
result = await super().save(model)
if result:
logger.debug(f"save: message saved successfully uid={model['uid']}")
else:
logger.warning(f"save: failed to save message uid={model['uid']}")
if model['is_final']:
self.delete_executor(model['uid'])
return result
@ -219,6 +207,6 @@ class ChannelMessageService(BaseService):
results.append(model)
except Exception as ex:
print(ex)
logger.error(f"offset query failed: {ex}")
results.sort(key=lambda x: x["created_at"])
return results

View File

@ -1,17 +1,29 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class ChatService(BaseService):
async def finalize(self, message_uid):
logger.info(f"finalize: starting for message_uid={message_uid}")
channel_message = await self.services.channel_message.get(uid=message_uid)
if not channel_message:
logger.warning(f"finalize: message not found uid={message_uid}")
return
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
logger.debug(f"finalize: message marked as final uid={message_uid}")
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
await self.services.socket.broadcast(
channel["uid"],
{
@ -28,18 +40,23 @@ class ChatService(BaseService):
"is_final": channel_message["is_final"],
},
)
logger.info(f"finalize: completed for message_uid={message_uid}, channel={channel['uid']}")
await self.app.create_task(
self.services.notification.create_channel_message(message_uid)
)
async def send(self, user_uid, channel_uid, message, is_final=True):
logger.info(f"send: user_uid={user_uid}, channel_uid={channel_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
logger.error(f"send: channel not found channel_uid={channel_uid}")
raise Exception("Channel not found.")
logger.debug(f"send: checking for existing non-final message in channel={channel_uid}")
channel_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False
)
if channel_message:
logger.debug(f"send: updating existing message uid={channel_message['uid']}")
channel_message["message"] = message
channel_message["is_final"] = is_final
if not channel_message["is_final"]:
@ -48,15 +65,18 @@ class ChatService(BaseService):
else:
await self.services.channel_message.save(channel_message)
else:
logger.debug(f"send: creating new message in channel={channel_uid}")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"]
logger.debug(f"send: message saved uid={channel_message_uid}")
user = await self.services.user.get(uid=user_uid)
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"send: broadcasting message to channel={channel_uid}")
await self.services.socket.broadcast(
channel_uid,
{
@ -73,6 +93,7 @@ class ChatService(BaseService):
"is_final": is_final,
},
)
logger.info(f"send: completed message_uid={channel_message_uid}, channel={channel_uid}, is_final={is_final}")
await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid)
)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.docker import ComposeFileManager
from snek.system.service import BaseService
@ -116,4 +118,3 @@ class ContainerService(BaseService):
if await super().save(model):
return model
raise Exception(f"Failed to create container: {model.errors}")

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import dataset
from snek.system.service import BaseService

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService

View File

@ -1,9 +1,13 @@
# retoor <retoor@molodetz.nl>
# services/forum.py
from snek.system.service import BaseService
import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
import asyncio
import inspect
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
@ -42,10 +46,12 @@ class BaseForumService(BaseService):
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if hasattr(listener, "__await__"): # async function or coro
if inspect.iscoroutinefunction(listener):
await listener(event_name, data)
else: # plain sync function
listener(event_name, data)
else:
result = listener(event_name, data)
if inspect.isawaitable(result):
await result
async def notify(self, event_name: str, data: Any) -> None:
"""
@ -159,7 +165,7 @@ class ThreadService(BaseForumService):
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"):
if not user["is_admin"]:
return None
thread["is_pinned"] = not thread["is_pinned"]
@ -227,7 +233,7 @@ class PostService(BaseForumService):
# Check permissions
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["is_admin"]:
return None
post["content"] = content
@ -246,7 +252,7 @@ class PostService(BaseForumService):
# Check permissions
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["is_admin"]:
return False
# Don't allow deleting first post

View File

@ -1,7 +1,13 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.markdown import strip_markdown
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class NotificationService(BaseService):
mapper_name = "notification"
@ -79,6 +85,6 @@ class NotificationService(BaseService):
},
)
except Exception as e:
print(f"Failed to send push notification:", e)
logger.warning(f"Failed to send push notification: {e}")
self.app.db.commit()

View File

@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from typing import Optional, List
from snek.system.service import BaseService
from snek.system.exceptions import ValidationError, DuplicateResourceError
logger = logging.getLogger(__name__)
class ProfilePageService(BaseService):
mapper_name = "profile_page"
def slugify(self, title: str) -> str:
slug = title.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug[:100]
async def create_page(self, user_uid: str, title: str, content: str = "", is_published: bool = True) -> dict:
slug = self.slugify(title)
existing = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if existing:
raise DuplicateResourceError(f"A page with slug '{slug}' already exists")
pages = [p async for p in self.find(user_uid=user_uid, deleted_at=None)]
max_order = max([p["order_index"] for p in pages], default=-1)
model = await self.new()
model["user_uid"] = user_uid
model["title"] = title
model["slug"] = slug
model["content"] = content
model["order_index"] = max_order + 1
model["is_published"] = is_published
await self.save(model)
return model
async def update_page(self, page_uid: str, title: Optional[str] = None,
content: Optional[str] = None, is_published: Optional[bool] = None) -> dict:
page = await self.get(uid=page_uid, deleted_at=None)
if not page:
raise ValidationError("Page not found")
if title is not None:
page["title"] = title
new_slug = self.slugify(title)
existing = await self.get(user_uid=page["user_uid"], slug=new_slug, deleted_at=None)
if existing and existing["uid"] != page_uid:
raise DuplicateResourceError(f"A page with slug '{new_slug}' already exists")
page["slug"] = new_slug
if content is not None:
page["content"] = content
if is_published is not None:
page["is_published"] = is_published
return await self.save(page)
async def get_user_pages(self, user_uid: str, include_unpublished: bool = False) -> List[dict]:
if include_unpublished:
pages = [p.record async for p in self.find(user_uid=user_uid, deleted_at=None)]
else:
pages = [p.record async for p in self.find(user_uid=user_uid, is_published=True, deleted_at=None)]
return sorted(pages, key=lambda p: p["order_index"])
async def get_page_by_slug(self, user_uid: str, slug: str, include_unpublished: bool = False) -> Optional[dict]:
page = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if page and (include_unpublished or page["is_published"]):
return page
return None
async def reorder_pages(self, user_uid: str, page_uids: List[str]) -> None:
for index, page_uid in enumerate(page_uids):
page = await self.get(uid=page_uid, user_uid=user_uid, deleted_at=None)
if page:
page["order_index"] = index
await self.save(page)
async def delete_page(self, page_uid: str) -> None:
page = await self.get(uid=page_uid, deleted_at=None)
if page:
await self.delete(page)

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import base64
import json
import os.path

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio
import shutil
@ -11,7 +13,7 @@ class RepositoryService(BaseService):
loop = asyncio.get_event_loop()
repository_path = (
await self.services.user.get_repository_path(user_uid)
).joinpath(name)
).joinpath(name + ".git")
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
@ -39,7 +41,7 @@ class RepositoryService(BaseService):
stdout, stderr = await process.communicate()
return process.returncode == 0
async def create(self, user_uid, name, is_private=False):
async def create(self, user_uid, name, is_private=False, description=None):
if await self.exists(user_uid=user_uid, name=name):
return False
@ -50,4 +52,14 @@ class RepositoryService(BaseService):
model["user_uid"] = user_uid
model["name"] = name
model["is_private"] = is_private
model["description"] = description or ""
return await self.save(model)
async def list_by_user(self, user_uid):
repositories = []
async for repo in self.find(user_uid=user_uid):
repositories.append(repo)
return repositories
async def get_by_name(self, user_uid, name):
return await self.get(user_uid=user_uid, name=name)

View File

@ -1,12 +1,36 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
from snek.system.model import now
logger = logging.getLogger(__name__)
from snek.system.model import now
def safe_get(obj, key, default=None):
if obj is None:
return default
try:
if isinstance(obj, dict):
return obj.get(key, default)
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
val = obj[key]
return val if val is not None else default
return getattr(obj, key, default)
except (KeyError, TypeError, AttributeError):
return default
def safe_str(obj):
if obj is None:
return ""
try:
return str(obj)
except Exception:
return ""
class SocketService(BaseService):
@ -16,23 +40,41 @@ class SocketService(BaseService):
self.ws = ws
self.is_connected = True
self.user = user
self.user_uid = safe_get(user, "uid") if user else None
self.user_color = safe_get(user, "color") if user else None
self.subscribed_channels = set()
async def send_json(self, data):
if data is None:
return False
if not self.is_connected:
return False
if not self.ws:
self.is_connected = False
return False
try:
await self.ws.send_json(data)
except Exception:
return True
except ConnectionResetError:
self.is_connected = False
return self.is_connected
logger.debug("Connection reset during send_json")
except Exception as ex:
self.is_connected = False
logger.debug(f"send_json failed: {safe_str(ex)}")
return False
async def close(self):
if not self.is_connected:
return True
await self.ws.close()
self.is_connected = False
self.subscribed_channels.clear()
try:
if self.ws and not self.ws.closed:
await asyncio.wait_for(self.ws.close(), timeout=5.0)
except asyncio.TimeoutError:
logger.debug("Socket close timed out")
except Exception as ex:
logger.debug(f"Socket close failed: {safe_str(ex)}")
return True
def __init__(self, app):
@ -41,74 +83,292 @@ class SocketService(BaseService):
self.users = {}
self.subscriptions = {}
self.last_update = str(datetime.now())
self._lock = asyncio.Lock()
async def user_availability_service(self):
logger.info("User availability update service started.")
logger.debug("Entering the main loop.")
while True:
logger.info("Updating user availability...")
logger.debug("Initializing users_updated list.")
users_updated = []
logger.debug("Iterating over sockets.")
for s in self.sockets:
logger.debug(f"Checking connection status for socket: {s}.")
if not s.is_connected:
logger.debug("Socket is not connected, continuing to next socket.")
continue
logger.debug(f"Checking if user {s.user} is already updated.")
if s.user not in users_updated:
logger.debug(f"Updating last_ping for user: {s.user}.")
s.user["last_ping"] = now()
logger.debug(f"Saving user {s.user} to the database.")
await self.app.services.user.save(s.user)
logger.debug(f"Adding user {s.user} to users_updated list.")
users_updated.append(s.user)
logger.info(
f"Updated user availability for {len(users_updated)} online users."
)
logger.debug("Sleeping for 60 seconds before the next update.")
await asyncio.sleep(60)
try:
sockets_copy = list(self.sockets)
users_to_update = []
seen_user_uids = set()
for s in sockets_copy:
try:
if not s or not s.is_connected:
continue
if not s.user or not s.user_uid:
continue
if s.user_uid in seen_user_uids:
continue
seen_user_uids.add(s.user_uid)
users_to_update.append(s.user_uid)
except Exception as ex:
logger.debug(f"Failed to check user availability: {safe_str(ex)}")
for user_uid in users_to_update:
try:
if self.app and hasattr(self.app, "services") and self.app.services:
user = await self.app.services.user.get(uid=user_uid)
if user:
user["last_ping"] = now()
await self.app.services.user.save(user)
except Exception as ex:
logger.debug(f"Failed to update user availability: {safe_str(ex)}")
logger.info(f"Updated user availability for {len(users_to_update)} online users.")
except Exception as ex:
logger.warning(f"User availability service error: {safe_str(ex)}")
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("User availability service cancelled")
break
async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
logger.info(f"Added socket for user {s.user['username']}")
if not self.users.get(user_uid):
self.users[user_uid] = set()
self.users[user_uid].add(s)
if not ws:
return None
if not user_uid:
return None
try:
if not self.app or not hasattr(self.app, "services") or not self.app.services:
logger.warning("Services not available for socket add")
return None
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.warning(f"User not found for socket add: {user_uid}")
return None
s = self.Socket(ws, user)
username = safe_get(user, "username", "unknown")
nick = safe_get(user, "nick") or username
color = s.user_color
async with self._lock:
self.sockets.add(s)
is_first_connection = False
if user_uid not in self.users:
self.users[user_uid] = set()
is_first_connection = True
elif len(self.users[user_uid]) == 0:
is_first_connection = True
self.users[user_uid].add(s)
try:
fresh_user = await self.app.services.user.get(uid=user_uid)
if fresh_user:
fresh_user["last_ping"] = now()
await self.app.services.user.save(fresh_user)
except Exception as ex:
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
logger.info(f"Added socket for user {username}")
if is_first_connection:
await self._broadcast_presence("arrived", user_uid, nick, color)
return s
except Exception as ex:
logger.warning(f"Failed to add socket: {safe_str(ex)}")
return None
async def subscribe(self, ws, channel_uid, user_uid):
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.subscriptions[channel_uid].add(s)
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
existing_socket = None
user_sockets = self.users.get(user_uid, set())
for sock in user_sockets:
if sock and sock.ws == ws:
existing_socket = sock
break
if not existing_socket:
return False
existing_socket.subscribed_channels.add(channel_uid)
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(user_uid)
return True
except Exception as ex:
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
return False
async def unsubscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
for s in self.sockets:
if s and s.ws == ws:
s.subscribed_channels.discard(channel_uid)
break
return True
except Exception as ex:
logger.warning(f"Failed to unsubscribe: {safe_str(ex)}")
return False
async def send_to_user(self, user_uid, message):
if not user_uid or message is None:
return 0
count = 0
for s in list(self.users.get(user_uid, [])):
if await s.send_json(message):
count += 1
try:
user_sockets = list(self.users.get(user_uid, []))
for s in user_sockets:
if not s:
continue
try:
if await s.send_json(message):
count += 1
except Exception as ex:
logger.debug(f"Failed to send to user socket: {safe_str(ex)}")
except Exception as ex:
logger.warning(f"send_to_user failed: {safe_str(ex)}")
return count
async def broadcast(self, channel_uid, message):
await self._broadcast(channel_uid, message)
if not channel_uid or message is None:
logger.debug(f"broadcast: invalid params channel_uid={channel_uid}, message={message is not None}")
return False
logger.debug(f"broadcast: starting for channel={channel_uid}")
return await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
if not channel_uid or message is None:
return False
sent = 0
user_uids_to_send = set()
try:
async for user_uid in self.services.channel_member.get_user_uids(
channel_uid
):
sent += await self.send_to_user(user_uid, message)
if self.services:
try:
async for user_uid in self.services.channel_member.get_user_uids(channel_uid):
if user_uid:
user_uids_to_send.add(user_uid)
logger.debug(f"_broadcast: found {len(user_uids_to_send)} users from db for channel={channel_uid}")
except Exception as ex:
logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
if not user_uids_to_send:
if channel_uid in self.subscriptions:
user_uids_to_send = set(self.subscriptions[channel_uid])
logger.debug(f"_broadcast: using {len(user_uids_to_send)} users from subscriptions for channel={channel_uid}")
send_tasks = []
for user_uid in user_uids_to_send:
send_tasks.append(self._send_to_user_safe(user_uid, message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
for result in results:
if isinstance(result, int):
sent += result
logger.info(f"_broadcast: completed channel={channel_uid}, total_users={len(user_uids_to_send)}, sent={sent}")
return True
except Exception as ex:
print(ex, flush=True)
logger.info(f"Broadcasted a message to {sent} users.")
return True
logger.warning(f"Broadcast failed: {safe_str(ex)}")
return False
async def _send_to_user_safe(self, user_uid, message):
try:
return await self.send_to_user(user_uid, message)
except Exception as ex:
logger.debug(f"Failed to send to user {user_uid}: {safe_str(ex)}")
return 0
async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close()
logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s)
if not ws:
return
async with self._lock:
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
for s in sockets_to_remove:
self.sockets.discard(s)
departures_to_broadcast = []
channels_to_cleanup = set()
for s in sockets_to_remove:
user_uid = s.user_uid
if not user_uid:
continue
is_last_connection = False
if user_uid in self.users:
self.users[user_uid].discard(s)
if len(self.users[user_uid]) == 0:
del self.users[user_uid]
is_last_connection = True
if is_last_connection:
user_nick = None
try:
if s.user:
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
except Exception:
pass
if user_nick:
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
for channel_uid in list(s.subscribed_channels):
channels_to_cleanup.add((channel_uid, user_uid))
for channel_uid, user_uid in channels_to_cleanup:
try:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
except Exception as ex:
logger.debug(f"Failed to cleanup channel subscription: {safe_str(ex)}")
for s in sockets_to_remove:
try:
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
logger.info(f"Removed socket for user {username}")
await s.close()
except Exception as ex:
logger.warning(f"Socket close failed: {safe_str(ex)}")
for user_uid, user_nick, user_color in departures_to_broadcast:
try:
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
except Exception as ex:
logger.debug(f"Failed to broadcast departure: {safe_str(ex)}")
async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color):
if not user_uid or not user_nick:
return
if not event_type or event_type not in ("arrived", "departed"):
return
try:
message = {
"event": "user_presence",
"data": {
"type": event_type,
"user_uid": user_uid,
"user_nick": user_nick,
"user_color": user_color,
"timestamp": datetime.now().isoformat(),
},
}
sockets_copy = list(self.sockets)
send_tasks = []
for s in sockets_copy:
if not s or not s.is_connected:
continue
if s.user_uid == user_uid:
continue
send_tasks.append(s.send_json(message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
sent_count = sum(1 for r in results if r is True)
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
except Exception as ex:
logger.warning(f"Broadcast presence failed: {safe_str(ex)}")
async def get_connected_users(self):
try:
return list(self.users.keys())
except Exception:
return []
async def get_user_socket_count(self, user_uid):
if not user_uid:
return 0
try:
return len(self.users.get(user_uid, []))
except Exception:
return 0
async def is_user_online(self, user_uid):
if not user_uid:
return False
try:
user_sockets = self.users.get(user_uid, set())
return any(s.is_connected for s in user_sockets if s)
except Exception:
return False

View File

@ -1,5 +1,9 @@
from snek.system.service import BaseService
import sqlite3
# retoor <retoor@molodetz.nl>
import sqlite3
from snek.system.service import BaseService
class StatisticsService(BaseService):

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import pathlib
from snek.system import security
@ -78,7 +80,7 @@ class UserService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
except OSError:
pass
return folder
@ -87,7 +89,7 @@ class UserService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
except OSError:
pass
return folder

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import json
from snek.system.service import BaseService
@ -15,6 +17,7 @@ class UserPropertyService(BaseService):
},
["user_uid", "name"],
)
self.mapper.db.commit()
async def get(self, user_uid, name):
try:

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import random
from snek.system.service import BaseService

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio
import base64
import json
@ -16,61 +18,72 @@ logger = logging.getLogger("git_server")
class GitApplication(web.Application):
def __init__(self, parent=None):
# import git
# globals()['git'] = git
import git
globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes(
[
web.post("/create/{repo_name}", self.create_repository),
web.delete("/delete/{repo_name}", self.delete_repository),
web.get("/clone/{repo_name}", self.clone_repository),
web.post("/push/{repo_name}", self.push_repository),
web.post("/pull/{repo_name}", self.pull_repository),
web.get("/status/{repo_name}", self.status_repository),
# web.get('/list', self.list_repositories),
web.get("/branches/{repo_name}", self.list_branches),
web.post("/branches/{repo_name}", self.create_branch),
web.get("/log/{repo_name}", self.commit_log),
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
web.get("/{path:.+}/info/refs", self.git_smart_http),
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
web.post("/{username}/{repo_name}/create", self.create_repository),
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
web.get("/{username}/{repo_name}/clone", self.clone_repository),
web.post("/{username}/{repo_name}/push", self.push_repository),
web.post("/{username}/{repo_name}/pull", self.pull_repository),
web.get("/{username}/{repo_name}/status", self.status_repository),
web.get("/{username}/{repo_name}/branches", self.list_branches),
web.post("/{username}/{repo_name}/branches", self.create_branch),
web.get("/{username}/{repo_name}/log", self.commit_log),
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None, None
return None, None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
request["user"] = await self.parent.services.user.authenticate(
request["auth_user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
if not request["user"]:
return None, None
if not request["auth_user"]:
return None, None, None
path_username = request.match_info.get("username")
if not path_username:
return None, None, None
if path_username.count("-") == 4:
target_user = await self.parent.services.user.get(uid=path_username)
else:
target_user = await self.parent.services.user.get(username=path_username)
if not target_user:
return None, None, None
request["target_user"] = target_user
request["repository_path"] = (
await self.parent.services.user.get_repository_path(request["user"]["uid"])
await self.parent.services.user.get_repository_path(target_user["uid"])
)
return request["user"]["username"], request["repository_path"]
return request["auth_user"]["username"], target_user, request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path:
username, target_user, repository_path = await self.check_basic_auth(request)
if not username or not target_user or not repository_path:
return web.Response(
status=401,
headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
request["target_user"] = target_user
request["repository_path"] = repository_path
return await handler(self, request, *args, **kwargs)
@ -87,9 +100,17 @@ class GitApplication(web.Application):
@require_auth
async def create_repository(self, request):
username = request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only create repositories in your own namespace",
status=403,
)
if not repo_name or "/" in repo_name or ".." in repo_name:
return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name)
@ -97,7 +118,7 @@ class GitApplication(web.Application):
return web.Response(text="Repository already exists", status=400)
try:
git.Repo.init(repo_dir, bare=True)
logger.info(f"Created repository: {repo_name} for user {username}")
logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Created repository {repo_name}")
except Exception as e:
logger.error(f"Error creating repository {repo_name}: {str(e)}")
@ -105,16 +126,22 @@ class GitApplication(web.Application):
@require_auth
async def delete_repository(self, request):
username = request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only delete your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
#'''
try:
shutil.rmtree(self.repo_path(repository_path, repo_name))
logger.info(f"Deleted repository: {repo_name} for user {username}")
logger.info(f"Deleted repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Deleted repository {repo_name}")
except Exception as e:
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
@ -122,9 +149,20 @@ class GitApplication(web.Application):
@require_auth
async def clone_repository(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -139,9 +177,16 @@ class GitApplication(web.Application):
@require_auth
async def push_repository(self, request):
username = request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only push to your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -175,14 +220,21 @@ class GitApplication(web.Application):
temp_repo.index.commit(commit_message)
origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {username}")
logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
username = request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only pull to your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -210,7 +262,7 @@ class GitApplication(web.Application):
origin = local_repo.remote("origin")
origin.push()
logger.info(
f"Pulled to repository {repo_name} from {remote_url} for user {username}"
f"Pulled to repository {repo_name} from {remote_url} for user {auth_user['username']}"
)
return web.Response(
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
@ -221,9 +273,20 @@ class GitApplication(web.Application):
@require_auth
async def status_repository(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -291,9 +354,20 @@ class GitApplication(web.Application):
@require_auth
async def list_branches(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -306,9 +380,17 @@ class GitApplication(web.Application):
@require_auth
async def create_branch(self, request):
username = request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only create branches in your own repositories",
status=403,
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -328,7 +410,7 @@ class GitApplication(web.Application):
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push("origin", branch_name)
logger.info(
f"Created branch {branch_name} in repository {repo_name} for user {username}"
f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
@ -339,9 +421,20 @@ class GitApplication(web.Application):
@require_auth
async def commit_log(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -383,11 +476,22 @@ class GitApplication(web.Application):
@require_auth
async def file_content(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
file_path = request.match_info.get("file_path", "")
branch = request.query.get("branch", "main")
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@ -433,25 +537,42 @@ class GitApplication(web.Application):
@require_auth
async def git_smart_http(self, request):
request["username"]
auth_user = request["auth_user"]
target_user = request["target_user"]
repository_path = request["repository_path"]
repo_name = request.match_info.get("repo_name")
path_username = request.match_info.get("username")
path = request.path
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
is_owner = auth_user["uid"] == target_user["uid"]
is_write_operation = "/git-receive-pack" in path
if is_write_operation and not is_owner:
logger.warning(
f"Push denied: {auth_user['username']} attempted to push to {path_username}/{repo_name}"
)
return web.Response(
text="Push denied: only repository owner can push", status=403
)
if not is_owner and repo["is_private"]:
logger.warning(
f"Access denied: {auth_user['username']} attempted to access private repo {path_username}/{repo_name}"
)
return web.Response(text="Repository not found", status=404)
async def get_repository_path():
req_path = path.lstrip("/")
if req_path.endswith("/info/refs"):
repo_name = req_path[: -len("/info/refs")]
elif req_path.endswith("/git-upload-pack"):
repo_name = req_path[: -len("/git-upload-pack")]
elif req_path.endswith("/git-receive-pack"):
repo_name = req_path[: -len("/git-receive-pack")]
else:
repo_name = req_path
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(f"Resolved repo path: {repo_dir}")
logger.info(
f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
)
return repo_dir
async def handle_info_refs(service):

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.app import Application
from IPython import start_ipython

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import aiohttp
ENABLED = False

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path

View File

@ -1,16 +1,10 @@
// Written by retoor@molodetz.nl
// This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints.
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
// retoor <retoor@molodetz.nl>
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient {
debug = false;
@ -160,9 +154,10 @@ export class App extends EventHandler {
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false
_debug = false;
presenceNotification = null;
async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid;
this.typeEventChannelUid = channel_uid;
}
debug() {
this._debug = !this._debug;
@ -174,17 +169,8 @@ export class App extends EventHandler {
await this.rpc.ping(...args);
this.is_pinging = false;
}
ntsh(times,message) {
if(!message)
message = "Nothing to see here!"
if(!times)
times=100
for(let x = 0; x < times; x++){
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
}
}
async forcePing(...arg) {
await this.rpc.ping(...args);
await this.rpc.ping(...arg);
}
starField = null
constructor() {
@ -192,6 +178,7 @@ export class App extends EventHandler {
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.presenceNotification = new PresenceNotification(this.ws);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
@ -202,7 +189,7 @@ export class App extends EventHandler {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
});
}, 1000);
const me = this;
this.ws.addEventListener("connected", (data) => {

View File

@ -66,11 +66,13 @@ header .logo {
}
header nav a {
color: #aaa;
color: #888;
text-decoration: none;
margin-left: 15px;
font-size: 1em;
transition: color 0.3s;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
}
.no-select {
@ -82,6 +84,7 @@ header nav a {
header nav a:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
a {
@ -407,34 +410,69 @@ a {
width: 250px;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
padding-top: 20px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
color: #f05a28;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-top: 20px;
}
.sidebar h2:first-child {
margin-top: 0;
}
.sidebar-add-btn {
color: #f05a28;
text-decoration: none;
font-size: 1.2em;
margin-bottom: 20px;
font-weight: bold;
margin-left: 8px;
opacity: 0.7;
transition: opacity 0.2s;
}
.sidebar-add-btn:hover {
opacity: 1;
}
.sidebar ul {
list-style: none;
}
.sidebar ul li {
margin-bottom: 15px;
}
.sidebar ul li {
margin-bottom: 4px;
}
.sidebar ul li a {
color: #ccc;
color: #888;
text-decoration: none;
font-size: 1em;
transition: color 0.3s;
font-family: 'Courier New', monospace;
font-size: 0.9em;
display: block;
padding: 6px 10px;
border-radius: 4px;
border-left: 2px solid transparent;
transition: all 0.2s ease;
}
.sidebar ul li a:hover {
color: #fff;
color: #e6e6e6;
background-color: #1a1a1a;
border-left-color: #444;
}
.sidebar ul li a.active {
color: #f05a28;
border-left-color: #f05a28;
}
@keyframes glow {
@ -459,31 +497,40 @@ a {
header {
top: 0;
left: 0;
text-overflow: ellipsis;
width: 100%;
display: flex;
flex-direction: column;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px;
padding: 10px 12px;
}
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
header > * {
display: inline-block !important;
vertical-align: middle;
}
h2 {
font-size: 14px;
}
header .logo {
flex: 1 1 auto !important;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
text-align: center;
}
header .logo h2 {
font-size: 14px;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
header nav {
display: none !important;
}
header nav-menu,
header channel-menu {
flex: 0 0 auto !important;
}
/*
@ -549,12 +596,22 @@ dialog .dialog-actions {
}
dialog .dialog-button {
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #e6e6e6;
cursor: pointer;
transition: background 0.2s ease;
transition: all 0.2s ease;
}
dialog .dialog-button:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
@ -580,38 +637,40 @@ dialog .dialog-button {
dialog .dialog-button.primary {
background-color: #f05a28;
color: white;
border-color: #f05a28;
color: #fff;
}
dialog .dialog-button.primary:hover {
background-color: #f05a28;
background-color: #e04924;
border-color: #e04924;
}
dialog .dialog-button.secondary {
background-color: #f0a328;
color: #eee;
background-color: #2a2a2a;
border-color: #444;
color: #e6e6e6;
}
dialog .dialog-button.secondary:hover {
background-color: #f0b84c;
background-color: #3a3a3a;
border-color: #555;
}
dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] {
/* slightly darker + lower saturation of the live colour */
background-color: #70321e; /* muted burnt orange */
color: #bfbfbf; /* light grey text */
opacity: .55; /* unified fade */
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
/* ---------- SECONDARY (yellow) ---------- */
dialog .dialog-button.secondary:disabled,
dialog .dialog-button.secondary[aria-disabled="true"] {
background-color: #6c5619; /* muted mustard */
color: #bfbfbf;
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
@ -621,6 +680,119 @@ dialog .dialog-button:disabled:focus {
outline: none;
}
dialog .dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
}
dialog .dialog-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
dialog .dialog-input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
dialog .dialog-input::placeholder {
color: #555;
}
dialog .dialog-input.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
dialog .dialog-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: #e6e6e6;
font-size: 14px;
cursor: pointer;
}
dialog .dialog-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #f05a28;
}
dialog .dialog-suggestions {
max-height: 150px;
overflow-y: auto;
}
dialog .dialog-suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
dialog .dialog-suggestion-item:hover {
background-color: #1a1a1a;
}
dialog .dialog-box-wide {
max-width: 500px;
}
dialog .dialog-label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
dialog .dialog-label-danger {
color: #8b0000;
}
dialog .dialog-divider {
height: 1px;
background-color: #333;
margin: 16px 0;
}
dialog .dialog-danger-zone {
padding: 12px;
border: 1px solid #8b0000;
border-radius: 4px;
background-color: rgba(139, 0, 0, 0.1);
}
dialog .dialog-button.danger {
background-color: #8b0000;
border-color: #8b0000;
color: #fff;
}
dialog .dialog-button.danger:hover {
background-color: #a00000;
border-color: #a00000;
}
dialog .dialog-button.danger:disabled {
background-color: #0a0a0a;
border-color: #222;
color: #555;
cursor: not-allowed;
}
.embed-url-link {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,93 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
background: #1a1a1a;
color: #e6e6e6;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
.btn:active {
background: #111;
transform: translateY(1px);
}
.btn:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
border-color: #222;
}
.btn-primary {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
.btn-primary:hover {
background: #e04924;
border-color: #e04924;
}
.btn-secondary {
background: #2a2a2a;
border-color: #444;
}
.btn-secondary:hover {
background: #3a3a3a;
border-color: #555;
}
.btn-danger {
background: #1a1a1a;
border-color: #8b0000;
color: #ff6b6b;
}
.btn-danger:hover {
background: #2a1515;
border-color: #b00;
}
.btn-success {
background: #1a1a1a;
border-color: #006400;
color: #6bff6b;
}
.btn-success:hover {
background: #152a15;
border-color: #0b0;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 14px 28px;
font-size: 16px;
}
.btn-block {
display: flex;
width: 100%;
}

View File

@ -0,0 +1,123 @@
/* retoor <retoor@molodetz.nl> */
channel-menu {
display: none;
position: relative;
}
@media (max-width: 768px) {
channel-menu {
display: inline-flex !important;
align-items: center;
}
}
.channel-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: none;
color: #888;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.2em;
transition: all 0.2s ease;
}
.channel-menu-toggle:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
channel-menu[open] .channel-menu-toggle {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-panel {
position: fixed;
top: 50px;
right: 8px;
left: 8px;
background-color: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px 0;
display: none;
flex-direction: column;
max-height: calc(100vh - 70px);
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
channel-menu[open] .channel-menu-panel {
display: flex;
}
.channel-menu-section {
padding: 8px 16px 4px;
font-size: 0.75em;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.channel-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
color: #888;
text-decoration: none;
font-size: 0.95em;
transition: all 0.2s ease;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.channel-menu-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-item.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.channel-menu-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-menu-count {
background: #f05a28;
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.8em;
min-width: 20px;
text-align: center;
}
.channel-menu-empty {
padding: 16px;
color: #666;
font-size: 0.9em;
text-align: center;
}
.channel-menu-divider {
height: 1px;
background: #333;
margin: 8px 0;
}

View File

@ -0,0 +1,171 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
class ChannelMenu extends HTMLElement {
constructor() {
super();
this._isOpen = false;
this._channels = [];
this._boundClickOutside = this._handleClickOutside.bind(this);
this._container = document.createElement('div');
this._container.className = 'channel-menu-container';
this._toggleButton = document.createElement('button');
this._toggleButton.className = 'channel-menu-toggle';
this._toggleButton.setAttribute('aria-label', 'Toggle channel menu');
this._toggleButton.innerHTML = 'đź’¬';
this._toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._menuPanel = document.createElement('div');
this._menuPanel.className = 'channel-menu-panel';
this._container.appendChild(this._toggleButton);
this._container.appendChild(this._menuPanel);
this.appendChild(this._container);
}
async connectedCallback() {
await this._loadChannels();
app.addEventListener('channel-message', (data) => {
if (data.is_final && data.channel_uid) {
this._incrementCount(data.channel_uid);
}
});
}
async _loadChannels() {
try {
this._channels = await app.rpc.getChannels();
this._renderChannels();
} catch (e) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">Failed to load channels</div>';
}
}
_renderChannels() {
this._menuPanel.innerHTML = '';
const publicChannels = this._channels.filter(c => !c.is_private);
const privateChannels = this._channels.filter(c => c.is_private);
if (publicChannels.length > 0) {
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Channels';
this._menuPanel.appendChild(header);
publicChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (privateChannels.length > 0) {
if (publicChannels.length > 0) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
this._menuPanel.appendChild(divider);
}
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Private';
this._menuPanel.appendChild(header);
privateChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (this._channels.length === 0) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">No channels available</div>';
}
}
_createChannelItem(channel) {
const item = document.createElement('a');
item.className = 'channel-menu-item';
item.href = `/channel/${channel.uid}.html`;
item.dataset.channelUid = channel.uid;
if (window.location.pathname.includes(channel.uid)) {
item.classList.add('active');
}
const name = document.createElement('span');
name.className = 'channel-menu-name';
name.textContent = channel.name;
if (channel.color) {
name.style.color = channel.color;
}
item.appendChild(name);
if (channel.new_count > 0) {
const count = document.createElement('span');
count.className = 'channel-menu-count';
count.textContent = channel.new_count;
item.appendChild(count);
}
item.addEventListener('click', () => {
this._close();
});
return item;
}
_incrementCount(channelUid) {
const item = this._menuPanel.querySelector(`[data-channel-uid="${channelUid}"]`);
if (item && !item.classList.contains('active')) {
let countEl = item.querySelector('.channel-menu-count');
if (!countEl) {
countEl = document.createElement('span');
countEl.className = 'channel-menu-count';
countEl.textContent = '1';
item.appendChild(countEl);
} else {
const current = parseInt(countEl.textContent) || 0;
countEl.textContent = current + 1;
}
}
}
_toggle() {
if (this._isOpen) {
this._close();
} else {
this._open();
}
}
_open() {
this._isOpen = true;
this.setAttribute('open', '');
this._loadChannels();
setTimeout(() => {
document.addEventListener('click', this._boundClickOutside);
}, 0);
}
_close() {
this._isOpen = false;
this.removeAttribute('open');
document.removeEventListener('click', this._boundClickOutside);
}
_handleClickOutside(event) {
if (!this.contains(event.target)) {
this._close();
}
}
disconnectedCallback() {
document.removeEventListener('click', this._boundClickOutside);
}
}
customElements.define('channel-menu', ChannelMenu);

View File

@ -1,6 +1,12 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
import { NjetComponent,eventBus } from "./njet.js";
import { NjetComponent, eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
import { loggerFactory } from "./logger.js";
import "./toolbar-menu.js";
const log = loggerFactory.getLogger("ChatInput");
class ChatInputComponent extends NjetComponent {
autoCompletions = {
@ -368,22 +374,16 @@ textToLeetAdvanced(text) {
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.appendChild(this.fileUploadGrid);
this.parentElement.insertBefore(this.fileUploadGrid, this);
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.ttsButton = document.createElement("stt-button");
this.snekSpeaker = document.createElement("snek-speaker");
this.appendChild(this.snekSpeaker);
this.ttsButton.addEventListener("click", (e) => {
this.snekSpeaker.enable()
});
this.sttButton = document.createElement("stt-button");
this.ttsButton = document.createElement("tts-button");
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
@ -398,10 +398,13 @@ textToLeetAdvanced(text) {
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
})
this.appendChild(this.uploadButton);
});
this.toolbarMenu = document.createElement("toolbar-menu");
this.toolbarMenu.addButton(this.sttButton, 'stt');
this.toolbarMenu.addButton(this.ttsButton, 'tts');
this.toolbarMenu.addButton(this.uploadButton, 'upload');
this.appendChild(this.toolbarMenu);
this.textarea.addEventListener("blur", () => {
this.updateFromInput(this.value, true).then(
@ -410,8 +413,6 @@ textToLeetAdvanced(text) {
});
this.subscribe("file-uploads-done", (data)=>{
this.textarea.style.display = "block";
this.uploadButton.style.display = "block";
this.fileUploadGrid.style.display = "none";
let msg =data.reduce((message, file) => {
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
@ -504,7 +505,10 @@ textToLeetAdvanced(text) {
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
log.debug("Flagging typing indicator", { channelUid: this.channelUid });
app.rpc.set_typing(this.channelUid, this.user?.color).catch((e) => {
log.warn("set_typing failed", { error: e, channelUid: this.channelUid });
});
}
}
@ -516,7 +520,12 @@ textToLeetAdvanced(text) {
}else if(this._leetSpeakAdvanced){
value = this.textToLeetAdvanced(value);
}
app.rpc.sendMessage(this.channelUid, value , true);
log.info("Finalizing message", { channelUid: this.channelUid, messageLength: value.length, messageUid });
app.rpc.sendMessage(this.channelUid, value , true).then((result) => {
log.debug("Message finalized successfully", { channelUid: this.channelUid, result });
}).catch((e) => {
log.error("Failed to finalize message", { channelUid: this.channelUid, error: e });
});
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
@ -526,6 +535,7 @@ textToLeetAdvanced(text) {
updateFromInput(value, isFinal = false) {
log.debug("updateFromInput called", { valueLength: value?.length, isFinal, liveType: this.liveType, channelUid: this.channelUid });
this.value = value;
@ -533,13 +543,22 @@ textToLeetAdvanced(text) {
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
log.debug("Sending live type message", { channelUid: this.channelUid, messageLength: messageText?.length, isFinal: !this.liveType || isFinal });
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid;
}
}
async sendMessage(channelUid, value, is_final) {
return await app.rpc.sendMessage(channelUid, value, is_final);
log.info("sendMessage called", { channelUid, valueLength: value?.length, is_final });
try {
const result = await app.rpc.sendMessage(channelUid, value, is_final);
log.debug("sendMessage completed", { channelUid, result, is_final });
return result;
} catch (e) {
log.error("sendMessage failed", { channelUid, error: e, is_final });
throw e;
}
}
}

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines a custom HTML element called ChatWindowElement that provides a chat interface within a shadow DOM, handling connection callbacks, displaying messages, and user interactions.

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
import { EventHandler } from "./event-handler.js";
@ -30,7 +32,7 @@ export class Container extends EventHandler{
}
refresh(){
//this._fitAddon.fit();
this.ws.send("\x0C");
this.ws.send(" ");
}
toggle(){
this._container.classList.toggle("hidden")
@ -110,4 +112,3 @@ export class Container extends EventHandler{
window.getContainer = function(){
return new Container(app.channelUid)
}*/

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent } from "/njet.js";
class WebTerminal extends NjetComponent {
@ -247,4 +249,3 @@ window.showTerm = function (options) {
customElements.define("web-terminal", WebTerminal);
export { WebTerminal };

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent } from "/njet.js"
class NjetEditor extends NjetComponent {
@ -279,7 +281,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
getCurrentLineInfo() {
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
const lines = text.split('
');
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
@ -405,7 +408,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
this.lastDeletedLine = lines[lineIndex];
lines.splice(lineIndex, 1);
if (lines.length === 0) lines.push('');
this.editor.innerText = lines.join('\n');
this.editor.innerText = lines.join('
');
this.setCaretOffset(lineStartOffset);
break;
@ -414,7 +418,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIndex + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.editor.innerText = lines.join('
');
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1);
}
break;

View File

@ -1,33 +1,178 @@
// retoor <retoor@molodetz.nl>
export class EventHandler {
constructor() {
this.subscribers = {};
this._maxListeners = 100;
this._warnOnMaxListeners = true;
}
addEventListener(type, handler, { once = false } = {}) {
if (!this.subscribers[type]) this.subscribers[type] = [];
if (once) {
const originalHandler = handler;
handler = (...args) => {
originalHandler(...args);
this.removeEventListener(type, handler);
};
addEventListener(type, handler, options = {}) {
if (!type || typeof type !== "string") {
console.warn("EventHandler: Invalid event type");
return false;
}
if (!handler || typeof handler !== "function") {
console.warn("EventHandler: Invalid handler");
return false;
}
try {
const once = options && options.once === true;
if (!this.subscribers[type]) {
this.subscribers[type] = [];
}
if (this._warnOnMaxListeners && this.subscribers[type].length >= this._maxListeners) {
console.warn(`EventHandler: Max listeners (${this._maxListeners}) reached for event "${type}"`);
}
let wrappedHandler = handler;
if (once) {
const originalHandler = handler;
wrappedHandler = (...args) => {
try {
originalHandler(...args);
} catch (e) {
console.error(`EventHandler: Error in once handler for "${type}":`, e);
} finally {
this.removeEventListener(type, wrappedHandler);
}
};
wrappedHandler._original = originalHandler;
}
this.subscribers[type].push(wrappedHandler);
return true;
} catch (e) {
console.error("EventHandler: addEventListener error:", e);
return false;
}
this.subscribers[type].push(handler);
}
emit(type, ...data) {
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
if (!type || typeof type !== "string") {
return false;
}
try {
const handlers = this.subscribers[type];
if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
return false;
}
const handlersCopy = [...handlers];
for (const handler of handlersCopy) {
if (typeof handler !== "function") {
continue;
}
try {
handler(...data);
} catch (e) {
console.error(`EventHandler: Error in handler for "${type}":`, e);
}
}
return true;
} catch (e) {
console.error("EventHandler: emit error:", e);
return false;
}
}
removeEventListener(type, handler) {
if (!this.subscribers[type]) return;
this.subscribers[type] = this.subscribers[type].filter(
(h) => h !== handler
);
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
if (!type || typeof type !== "string") {
return false;
}
try {
if (!this.subscribers[type]) {
return false;
}
if (!handler) {
delete this.subscribers[type];
return true;
}
const originalLength = this.subscribers[type].length;
this.subscribers[type] = this.subscribers[type].filter((h) => {
if (h === handler) return false;
if (h._original && h._original === handler) return false;
return true;
});
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
}
return this.subscribers[type] ? this.subscribers[type].length < originalLength : true;
} catch (e) {
console.error("EventHandler: removeEventListener error:", e);
return false;
}
}
removeAllEventListeners(type) {
try {
if (type) {
if (this.subscribers[type]) {
delete this.subscribers[type];
return true;
}
return false;
}
this.subscribers = {};
return true;
} catch (e) {
console.error("EventHandler: removeAllEventListeners error:", e);
return false;
}
}
hasEventListener(type, handler) {
if (!type || typeof type !== "string") {
return false;
}
try {
if (!this.subscribers[type]) {
return false;
}
if (!handler) {
return this.subscribers[type].length > 0;
}
return this.subscribers[type].some((h) => {
if (h === handler) return true;
if (h._original && h._original === handler) return true;
return false;
});
} catch (e) {
return false;
}
}
getEventListenerCount(type) {
try {
if (type) {
return this.subscribers[type] ? this.subscribers[type].length : 0;
}
let count = 0;
for (const key in this.subscribers) {
if (Object.prototype.hasOwnProperty.call(this.subscribers, key)) {
count += this.subscribers[key].length;
}
}
return count;
} catch (e) {
return 0;
}
}
getEventTypes() {
try {
return Object.keys(this.subscribers);
} catch (e) {
return [];
}
}
once(type, handler) {
return this.addEventListener(type, handler, { once: true });
}
off(type, handler) {
return this.removeEventListener(type, handler);
}
on(type, handler) {
return this.addEventListener(type, handler);
}
}

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
@ -28,21 +30,30 @@ class FancyButton extends HTMLElement {
button {
width: var(--width);
min-width: ${size};
padding: 10px;
background-color: #f05a28;
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
border: 1px solid #f05a28;
transition: all 0.2s ease;
}
button:hover {
color: #EFEFEF;
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
button.primary:hover {
background-color: #e04924;
border: 1px solid #efefef;
border-color: #e04924;
}
`;

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
/* A <file-browser> custom element that talks to /api/files */
import { NjetComponent } from "/njet.js";

View File

@ -1,23 +1,32 @@
.fug-root {
background: #181818;
background: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px;
margin: 0 15px 8px 15px;
color: white;
font-family: sans-serif;
/*min-height: 100vh;*/
max-width: calc(100% - 30px);
}
.fug-grid {
display: grid;
grid-template-columns: repeat(6, 150px);
gap: 20px;
margin: 30px;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 10px;
max-width: 100%;
overflow-x: auto;
justify-content: flex-start;
}
.fug-tile {
width: 120px;
flex-shrink: 0;
background: #111;
border: 2px solid #ff6600;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px 8px 8px;
padding: 12px 8px 8px 8px;
box-shadow: 0 0 4px #333;
position: relative;
}

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent, NjetDialog } from '/njet.js';
const FUG_ICONS = {

View File

@ -2,11 +2,11 @@
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
}
body {
font-family: 'Courier New', monospace;
background-color: #0a0a0a;
color: #e6e6e6;
line-height: 1.5;
display: flex;
@ -15,61 +15,72 @@
align-items: center;
height: 100vh;
width: 100vw;
}
}
generic-form {
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: #000000;
}
.generic-form-container {
background-color: #0f0f0f;
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
.generic-form-container h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
}
input {
border: 10px solid #000000;
}
.generic-form-container generic-field {
width: 100%;
padding: 10px;
padding: 10px 12px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 5px;
background-color: #1a1a1a;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-size: 1em;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.generic-form-container button {
width: 100%;
padding: 10px;
background-color: #f05a28;
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
transition: all 0.2s ease;
}
.generic-form-container button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
.generic-form-container button[type="submit"],
.generic-form-container button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
.generic-form-container button[type="submit"]:hover,
.generic-form-container button.primary:hover {
background-color: #e04924;
border-color: #e04924;
}
.generic-form-container a {
@ -85,15 +96,13 @@ input {
color: #e04924;
}
.error {
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
@media (max-width: 600px) {
.generic-form-container {
width: 90%;
}
}

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines two custom HTML elements, `GenericField` and `GenericForm`. The `GenericField` element represents a form field with validation and styling functionalities, and the `GenericForm` fetches and manages form data, handling field validation and submission.
@ -76,47 +78,66 @@ class GenericField extends HTMLElement {
input {
width: 90%;
padding: 10px;
padding: 10px 12px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 5px;
background-color: #1a1a1a;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-size: 1em;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: 2px solid #f05a28 !important;
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
&::placeholder {
color: #555;
transition: opacity 0.3s;
}
&:focus::placeholder {
opacity: 0.4;
}
}
button {
width: 50%;
padding: 10px;
background-color: #f05a28;
border: none;
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
float: right;
margin-top: 10px;
margin-left: 10px;
margin-right: 10px;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
transition: all 0.2s ease;
clear: both;
}
button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
button[value="submit"], button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
button[value="submit"]:hover, button.primary:hover {
background-color: #e04924;
border-color: #e04924;
}
a {
@ -133,17 +154,13 @@ class GenericField extends HTMLElement {
}
.valid {
border: 1px solid green;
color: green;
font-size: 0.9em;
margin-top: 5px;
border-color: #006400;
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
}
.error {
border: 3px solid red;
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
@media (max-width: 500px) {

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// The following JavaScript code defines a custom HTML element `<html-frame>` that loads and displays HTML content from a specified URL. If the URL is provided as a markdown file, it attempts to render it as HTML.

102
src/snek/static/inputs.css Normal file
View File

@ -0,0 +1,102 @@
.input {
width: 100%;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
.input:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
}
.input::placeholder {
color: #555;
}
.input-error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
.input-success {
border-color: #006400;
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
}
.textarea {
width: 100%;
min-height: 100px;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
resize: vertical;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.textarea:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
.select {
width: 100%;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #f05a28;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
accent-color: #f05a28;
cursor: pointer;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.5px;
}

100
src/snek/static/lists.css Normal file
View File

@ -0,0 +1,100 @@
.settings-list {
display: flex;
flex-direction: column;
gap: 0;
}
.settings-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
flex-wrap: wrap;
gap: 15px;
}
.settings-list-info {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
min-width: 200px;
}
.settings-list-title {
font-size: 1.1rem;
font-weight: 600;
color: #e6e6e6;
display: flex;
align-items: center;
gap: 10px;
}
.settings-list-title i {
color: #888;
}
.settings-list-meta {
color: #888;
font-size: 0.9em;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.settings-list-meta code {
background: #0f0f0f;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.settings-list-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.85em;
color: #888;
}
.settings-list-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.settings-topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.settings-topbar h2 {
margin: 0;
font-size: 1.3rem;
color: #e6e6e6;
}
.settings-empty {
text-align: center;
padding: 60px 20px;
}
.settings-empty p {
color: #888;
font-size: 1.1em;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.settings-list-item {
flex-direction: column;
align-items: stretch;
}
.settings-list-actions {
justify-content: flex-start;
}
}

98
src/snek/static/logger.js Normal file
View File

@ -0,0 +1,98 @@
// retoor <retoor@molodetz.nl>
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
class Logger {
constructor(name, level = LogLevel.DEBUG) {
this.name = name;
this.level = level;
this.enabled = true;
}
_format(level, message, data) {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level}] [${this.name}]`;
return { prefix, message, data };
}
_log(level, levelName, message, data) {
if (!this.enabled || level < this.level) return;
const { prefix } = this._format(levelName, message, data);
const logFn = level === LogLevel.ERROR ? console.error :
level === LogLevel.WARN ? console.warn :
level === LogLevel.INFO ? console.info : console.debug;
if (data !== undefined) {
logFn(`${prefix} ${message}`, data);
} else {
logFn(`${prefix} ${message}`);
}
}
debug(message, data) {
this._log(LogLevel.DEBUG, "DEBUG", message, data);
}
info(message, data) {
this._log(LogLevel.INFO, "INFO", message, data);
}
warn(message, data) {
this._log(LogLevel.WARN, "WARN", message, data);
}
error(message, data) {
this._log(LogLevel.ERROR, "ERROR", message, data);
}
setLevel(level) {
this.level = level;
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
}
class LoggerFactory {
constructor() {
this.loggers = new Map();
this.globalLevel = LogLevel.DEBUG;
this.globalEnabled = true;
}
getLogger(name) {
if (!this.loggers.has(name)) {
const logger = new Logger(name, this.globalLevel);
logger.enabled = this.globalEnabled;
this.loggers.set(name, logger);
}
return this.loggers.get(name);
}
setGlobalLevel(level) {
this.globalLevel = level;
this.loggers.forEach((logger) => logger.setLevel(level));
}
enableAll() {
this.globalEnabled = true;
this.loggers.forEach((logger) => logger.enable());
}
disableAll() {
this.globalEnabled = false;
this.loggers.forEach((logger) => logger.disable());
}
}
export const loggerFactory = new LoggerFactory();
export { Logger, LogLevel };

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This JavaScript class defines a custom HTML element <markdown-frame> that fetches and loads content from a specified URL into a shadow DOM.

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines custom web components to create and interact with a tile grid system for displaying images, along with an upload button to facilitate image additions.

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.

View File

@ -1,10 +1,5 @@
// Written by retoor@molodetz.nl
// retoor <retoor@molodetz.nl>
// This class defines a custom HTML element that displays a list of messages with avatars and timestamps. It handles message addition with a delay in event dispatch and ensures the display of messages in the correct format.
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "./app.js";
const LONG_TIME = 1000 * 60 * 20;
@ -162,12 +157,10 @@ class MessageList extends HTMLElement {
threshold: 0,
});
// End-of-messages marker
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
// Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
@ -175,19 +168,30 @@ class MessageList extends HTMLElement {
}
}
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
this._updateMessageHandler = (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
};
this._typingHandler = (data) => {
if (app._debug) console.debug("set_typing event received:", data);
this.triggerGlow(data.user_uid, data.color);
});
};
app.ws.addEventListener("update_message_text", this._updateMessageHandler);
app.ws.addEventListener("set_typing", this._typingHandler);
this.scrollToBottom(true);
}
disconnectedCallback() {
if (this._observer) {
this._observer.disconnect();
}
app.ws.removeEventListener("update_message_text", this._updateMessageHandler);
app.ws.removeEventListener("set_typing", this._typingHandler);
}
connectedCallback() {
this.addEventListener('click', (e) => {
if (
@ -256,8 +260,8 @@ class MessageList extends HTMLElement {
}
triggerGlow(uid, color) {
if (!uid || !color) return;
app.starField.glowColor(color);
if (color && app.starField) app.starField.glowColor(color);
if (!uid) return;
let lastElement = null;
this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a');
@ -285,12 +289,6 @@ class MessageList extends HTMLElement {
upsertMessage(data) {
let message = this.messageMap.get(data.uid);
if (message && (data.is_final || !data.message)) {
//message.parentElement?.removeChild(message);
// TO force insert
//message = null;
}
if(message && !data.message){
message.parentElement?.removeChild(message);
message = null;

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines a class 'MessageModel' representing a message entity with various properties such as user and channel IDs, message content, and timestamps. It includes a constructor to initialize these properties.

Some files were not shown because too many files have changed in this diff Show More