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] [project]
name = "Snek" name = "Snek"
version = "1.0.0" version = "1.24.0"
readme = "README.md" readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" } #license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz" description = "Snek Chat Application by Molodetz"
@ -40,7 +40,8 @@ dependencies = [
"pillow-heif", "pillow-heif",
"IP2Location", "IP2Location",
"bleach", "bleach",
"sentry-sdk" "sentry-sdk",
"bcrypt"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@ -51,3 +52,10 @@ where = ["src"] # <-- this changed
[project.scripts] [project.scripts]
snek = "snek.__main__:main" 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 MIT License

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio import asyncio
from snek.app import app 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 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 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 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 from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
# forum_app.py # forum_app.py
import aiohttp.web import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
@ -78,31 +80,9 @@ class ForumApplication(aiohttp.web.Application):
async def serve_forum_html(self, request): async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component""" """Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request) return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app # Integration with main app
def setup_forum(app): def setup_forum(app):
"""Set up forum sub-application""" """Set up forum sub-application"""

View File

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

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import functools import functools
from snek.mapper.channel import ChannelMapper 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.repository import RepositoryMapper
from snek.mapper.user import UserMapper from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.profile_page import ProfilePageMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object from snek.system.object import Object
@ -36,6 +39,7 @@ def get_mappers(app=None):
"thread": ThreadMapper(app=app), "thread": ThreadMapper(app=app),
"post": PostMapper(app=app), "post": PostMapper(app=app),
"post_like": PostLikeMapper(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.model.channel import ChannelModel
from snek.system.mapper import BaseMapper 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.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper 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.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper 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.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper from snek.system.mapper import BaseMapper

View File

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

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive import DriveModel from snek.model.drive import DriveModel
from snek.system.mapper import BaseMapper 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.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper from snek.system.mapper import BaseMapper

View File

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

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.notification import NotificationModel from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper 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.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper from snek.system.mapper import BaseMapper

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField from snek.system.model import BaseModel, ModelField
class UserPropertyModel(BaseModel): class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str) user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str) name = ModelField(name="name", required=True, kind=str)
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 asyncio
import aiohttp import json
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
class DatasetMethod: class DatasetMethod:
def __init__(self, dt, name): pass
self.dt = dt
self.name = name
def __call__(self, *args, **kwargs):
return self.dt.ds.call(
self.dt.name,
self.name,
*args,
**kwargs
)
class DatasetTable: class DatasetTable:
pass
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
class WebSocketClient2: class WebSocketClient2:
@ -43,200 +19,40 @@ class WebSocketClient2:
self.websocket = None self.websocket = None
self.receive_queue = asyncio.Queue() 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): def send(self, message: str):
if self.loop.is_running(): pass
# 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()
def close(self): def close(self):
if self.websocket: pass
if self.loop.is_running():
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
else:
self.loop.run_until_complete(self.websocket.close())
import websockets class DatasetWrapper:
class DatasetWrapper(object):
def __init__(self): def __init__(self):
self.ws = WebSocketClient() pass
def begin(self):
self.call(None, 'begin')
def commit(self): def commit(self):
self.call(None, 'commit') pass
def __getitem__(self, name):
return DatasetTable(self, name)
def query(self, *args, **kwargs): def query(self, *args, **kwargs):
return self.call(None, 'query', *args, **kwargs) pass
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
class DatasetWebSocketView: class DatasetWebSocketView:
def __init__(self): def __init__(self):
self.ws = None 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): def format_result(self, result):
try:
return dict(result)
except:
pass pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg): async def send_str(self, msg):
return await self.ws.send_str(msg) pass
def get(self, key): def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value'])) pass
def set(self, key, value): 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(): async def run_server():
pass
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())

View File

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

View File

@ -1,32 +1,33 @@
CREATE TABLE IF NOT EXISTS http_access ( CREATE TABLE user (
id INTEGER NOT NULL,
created TEXT,
path TEXT,
duration FLOAT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL, id INTEGER NOT NULL,
city TEXT,
color TEXT, color TEXT,
country_long TEXT,
country_short TEXT,
created_at TEXT, created_at TEXT,
deleted_at TEXT, deleted_at TEXT,
email TEXT, email TEXT,
ip TEXT,
is_admin TEXT, is_admin TEXT,
last_ping TEXT, last_ping TEXT,
latitude TEXT,
longitude TEXT,
nick TEXT, nick TEXT,
password TEXT, password TEXT,
region TEXT,
uid TEXT, uid TEXT,
updated_at TEXT, updated_at TEXT,
username TEXT, username TEXT,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid); CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS channel ( CREATE TABLE channel (
id INTEGER NOT NULL, id INTEGER NOT NULL,
created_at TEXT, created_at TEXT,
created_by_uid TEXT, created_by_uid TEXT,
deleted_at TEXT, deleted_at TEXT,
description TEXT, description TEXT,
history_start TEXT,
"index" BIGINT, "index" BIGINT,
is_listed BOOLEAN, is_listed BOOLEAN,
is_private BOOLEAN, is_private BOOLEAN,
@ -37,8 +38,8 @@ CREATE TABLE IF NOT EXISTS channel (
updated_at TEXT, updated_at TEXT,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid); CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member ( CREATE TABLE channel_member (
id INTEGER NOT NULL, id INTEGER NOT NULL,
channel_uid TEXT, channel_uid TEXT,
created_at TEXT, created_at TEXT,
@ -54,28 +55,31 @@ CREATE TABLE IF NOT EXISTS channel_member (
user_uid TEXT, user_uid TEXT,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid); CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE IF NOT EXISTS broadcast ( CREATE TABLE channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
message TEXT,
created_at TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channel_message (
id INTEGER NOT NULL, id INTEGER NOT NULL,
channel_uid TEXT, channel_uid TEXT,
created_at TEXT, created_at TEXT,
deleted_at TEXT, deleted_at TEXT,
html TEXT, html TEXT,
is_final BOOLEAN,
message TEXT, message TEXT,
uid TEXT, uid TEXT,
updated_at TEXT, updated_at TEXT,
user_uid TEXT, user_uid TEXT,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid); CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification ( 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, id INTEGER NOT NULL,
created_at TEXT, created_at TEXT,
deleted_at TEXT, deleted_at TEXT,
@ -88,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
user_uid TEXT, user_uid TEXT,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid); CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository ( 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, id INTEGER NOT NULL,
created_at TEXT, created_at TEXT,
deleted_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, is_private BIGINT,
name TEXT, name TEXT,
uid TEXT, uid TEXT,
@ -100,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
user_uid TEXT, user_uid TEXT,
PRIMARY KEY (id) 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 import functools
from snek.service.channel import ChannelService from snek.service.channel import ChannelService
@ -19,6 +21,7 @@ from snek.service.util import UtilService
from snek.system.object import Object from snek.system.object import Object
from snek.service.statistics import StatisticsService from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
from snek.service.profile_page import ProfilePageService
_service_registry = {} _service_registry = {}
def register_service(name, service_cls): def register_service(name, service_cls):
@ -40,7 +43,7 @@ def get_services(app):
def get_service(name, app=None): def get_service(name, app=None):
return get_services(app=app)[name] return get_services(app=app)[name]
# Registering all services
register_service("user", UserService) register_service("user", UserService)
register_service("channel_member", ChannelMemberService) register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService) register_service("channel", ChannelService)
@ -62,4 +65,5 @@ register_service("forum", ForumService)
register_service("thread", ThreadService) register_service("thread", ThreadService)
register_service("post", PostService) register_service("post", PostService)
register_service("post_like", PostLikeService) register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)

View File

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

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import mimetypes import mimetypes
from snek.system.service import BaseService 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 from snek.system.service import BaseService
@ -8,11 +11,12 @@ class ChannelMemberService(BaseService):
async def mark_as_read(self, channel_uid, user_uid): 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 = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0 channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member) return await self.save(channel_member)
async def get_user_uids(self, channel_uid): async def get_user_uids(self, channel_uid):
async for model in self.mapper.query( 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}, {"channel_uid": channel_uid},
): ):
yield model["user_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 ", "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}, {"from_user": from_user, "to_user": to_user},
): ):
return model return model
async def get_other_dm_user(self, channel_uid, user_uid): 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.service import BaseService
from snek.system.template import sanitize_html from snek.system.template import sanitize_html
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader logger = logging.getLogger(__name__)
global jinja2_env jinja2_env = None
import pathlib
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates") template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context): def render(context):
template =jinja2_env.get_template("message.html") template = jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context)) return sanitize_html(template.render(**context))
@ -27,10 +30,11 @@ class ChannelMessageService(BaseService):
global jinja2_env global jinja2_env
jinja2_env = self.app.jinja2_env jinja2_env = self.app.jinja2_env
self._max_workers = 1 self._max_workers = 1
def get_or_create_executor(self, uid): def get_or_create_executor(self, uid):
if not uid in self._executor_pools: if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=self._max_workers) 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] return self._executor_pools[uid]
def delete_executor(self, uid): def delete_executor(self, uid):
@ -39,34 +43,6 @@ class ChannelMessageService(BaseService):
del self._executor_pools[uid] del self._executor_pools[uid]
async def maintenance(self): 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: while True:
changed = 0 changed = 0
async for message in self.find(is_final=False): async for message in self.find(is_final=False):
@ -81,6 +57,7 @@ class ChannelMessageService(BaseService):
break break
async def create(self, channel_uid, user_uid, message, is_final=True): 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 = await self.new()
model["channel_uid"] = channel_uid model["channel_uid"] = channel_uid
@ -93,6 +70,9 @@ class ChannelMessageService(BaseService):
record = model.record record = model.record
context.update(record) context.update(record)
user = await self.app.services.user.get(uid=user_uid) 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( context.update(
{ {
"user_uid": user["uid"], "user_uid": user["uid"],
@ -103,15 +83,13 @@ class ChannelMessageService(BaseService):
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: 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(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: 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 await super().save(model):
if not self._configured_indexes: if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index( if not self.mapper.db["channel_message"].has_index(
@ -129,7 +107,9 @@ class ChannelMessageService(BaseService):
self._configured_indexes = True self._configured_indexes = True
if model['is_final']: if model['is_final']:
self.delete_executor(model['uid']) self.delete_executor(model['uid'])
logger.info(f"create: message created successfully uid={model['uid']}, channel={channel_uid}")
return model 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}.") raise Exception(f"Failed to create channel message: {model.errors}.")
async def to_extended_dict(self, message): async def to_extended_dict(self, message):
@ -154,9 +134,13 @@ class ChannelMessageService(BaseService):
} }
async def save(self, model): async def save(self, model):
logger.debug(f"save: starting for uid={model['uid']}, is_final={model['is_final']}")
context = {} context = {}
context.update(model.record) context.update(model.record)
user = await self.app.services.user.get(model["user_uid"]) user = await self.app.services.user.get(model["user_uid"])
if not user:
logger.error(f"save: user not found user_uid={model['user_uid']}")
return False
context.update( context.update(
{ {
"user_uid": user["uid"], "user_uid": user["uid"],
@ -167,10 +151,14 @@ class ChannelMessageService(BaseService):
) )
context = json.loads(json.dumps(context, default=str)) context = json.loads(json.dumps(context, default=str))
loop = asyncio.get_event_loop() 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(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) 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']: if model['is_final']:
self.delete_executor(model['uid']) self.delete_executor(model['uid'])
return result return result
@ -219,6 +207,6 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
except Exception as ex: except Exception as ex:
print(ex) logger.error(f"offset query failed: {ex}")
results.sort(key=lambda x: x["created_at"]) results.sort(key=lambda x: x["created_at"])
return results return results

View File

@ -1,17 +1,29 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now from snek.system.model import now
from snek.system.service import BaseService from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class ChatService(BaseService): class ChatService(BaseService):
async def finalize(self, message_uid): 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) 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 channel_message["is_final"] = True
await self.services.channel_message.save(channel_message) 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"]) user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"]) channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now() channel["last_message_on"] = now()
await self.services.channel.save(channel) await self.services.channel.save(channel)
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
await self.services.socket.broadcast( await self.services.socket.broadcast(
channel["uid"], channel["uid"],
{ {
@ -28,18 +40,23 @@ class ChatService(BaseService):
"is_final": channel_message["is_final"], "is_final": channel_message["is_final"],
}, },
) )
logger.info(f"finalize: completed for message_uid={message_uid}, channel={channel['uid']}")
await self.app.create_task( await self.app.create_task(
self.services.notification.create_channel_message(message_uid) self.services.notification.create_channel_message(message_uid)
) )
async def send(self, user_uid, channel_uid, message, is_final=True): async def send(self, user_uid, channel_uid, message, is_final=True):
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) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
logger.error(f"send: channel not found channel_uid={channel_uid}")
raise Exception("Channel not found.") 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_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False channel_uid=channel_uid,user_uid=user_uid, is_final=False
) )
if channel_message: if channel_message:
logger.debug(f"send: updating existing message uid={channel_message['uid']}")
channel_message["message"] = message channel_message["message"] = message
channel_message["is_final"] = is_final channel_message["is_final"] = is_final
if not channel_message["is_final"]: if not channel_message["is_final"]:
@ -48,15 +65,18 @@ class ChatService(BaseService):
else: else:
await self.services.channel_message.save(channel_message) await self.services.channel_message.save(channel_message)
else: else:
logger.debug(f"send: creating new message in channel={channel_uid}")
channel_message = await self.services.channel_message.create( channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final channel_uid, user_uid, message, is_final
) )
channel_message_uid = channel_message["uid"] channel_message_uid = channel_message["uid"]
logger.debug(f"send: message saved uid={channel_message_uid}")
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
channel["last_message_on"] = now() channel["last_message_on"] = now()
await self.services.channel.save(channel) await self.services.channel.save(channel)
logger.debug(f"send: broadcasting message to channel={channel_uid}")
await self.services.socket.broadcast( await self.services.socket.broadcast(
channel_uid, channel_uid,
{ {
@ -73,6 +93,7 @@ class ChatService(BaseService):
"is_final": is_final, "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( await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid) 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.docker import ComposeFileManager
from snek.system.service import BaseService from snek.system.service import BaseService
@ -116,4 +118,3 @@ class ContainerService(BaseService):
if await super().save(model): if await super().save(model):
return model return model
raise Exception(f"Failed to create container: {model.errors}") raise Exception(f"Failed to create container: {model.errors}")

View File

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

View File

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

View File

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

View File

@ -1,9 +1,13 @@
# retoor <retoor@molodetz.nl>
# services/forum.py # services/forum.py
from snek.system.service import BaseService from snek.system.service import BaseService
import re import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List from typing import Any, Awaitable, Callable, Dict, List
import asyncio
import inspect
from snek.system.model import now from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None] EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService): class BaseForumService(BaseService):
@ -42,10 +46,12 @@ class BaseForumService(BaseService):
async def _dispatch_event(self, event_name: str, data: Any) -> None: async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event.""" """Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []): 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) await listener(event_name, data)
else: # plain sync function else:
listener(event_name, data) result = listener(event_name, data)
if inspect.isawaitable(result):
await result
async def notify(self, event_name: str, data: Any) -> None: async def notify(self, event_name: str, data: Any) -> None:
""" """
@ -159,7 +165,7 @@ class ThreadService(BaseForumService):
# Check if user is admin # Check if user is admin
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"): if not user["is_admin"]:
return None return None
thread["is_pinned"] = not thread["is_pinned"] thread["is_pinned"] = not thread["is_pinned"]
@ -227,7 +233,7 @@ class PostService(BaseForumService):
# Check permissions # Check permissions
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"): if post["created_by_uid"] != user_uid and not user["is_admin"]:
return None return None
post["content"] = content post["content"] = content
@ -246,7 +252,7 @@ class PostService(BaseForumService):
# Check permissions # Check permissions
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"): if post["created_by_uid"] != user_uid and not user["is_admin"]:
return False return False
# Don't allow deleting first post # 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.markdown import strip_markdown
from snek.system.model import now from snek.system.model import now
from snek.system.service import BaseService from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class NotificationService(BaseService): class NotificationService(BaseService):
mapper_name = "notification" mapper_name = "notification"
@ -79,6 +85,6 @@ class NotificationService(BaseService):
}, },
) )
except Exception as e: 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() 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 base64
import json import json
import os.path import os.path

View File

@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import asyncio import asyncio
import shutil import shutil
@ -11,7 +13,7 @@ class RepositoryService(BaseService):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
repository_path = ( repository_path = (
await self.services.user.get_repository_path(user_uid) await self.services.user.get_repository_path(user_uid)
).joinpath(name) ).joinpath(name + ".git")
try: try:
await loop.run_in_executor(None, shutil.rmtree, repository_path) await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex: except Exception as ex:
@ -39,7 +41,7 @@ class RepositoryService(BaseService):
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return process.returncode == 0 return process.returncode == 0
async def create(self, user_uid, name, is_private=False): async def create(self, user_uid, name, is_private=False, description=None):
if await self.exists(user_uid=user_uid, name=name): if await self.exists(user_uid=user_uid, name=name):
return False return False
@ -50,4 +52,14 @@ class RepositoryService(BaseService):
model["user_uid"] = user_uid model["user_uid"] = user_uid
model["name"] = name model["name"] = name
model["is_private"] = is_private model["is_private"] = is_private
model["description"] = description or ""
return await self.save(model) 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 asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from snek.model.user import UserModel from snek.model.user import UserModel
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.model import now
logger = logging.getLogger(__name__) 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): class SocketService(BaseService):
@ -16,23 +40,41 @@ class SocketService(BaseService):
self.ws = ws self.ws = ws
self.is_connected = True self.is_connected = True
self.user = user 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): async def send_json(self, data):
if data is None:
return False
if not self.is_connected: if not self.is_connected:
return False return False
if not self.ws:
self.is_connected = False
return False
try: try:
await self.ws.send_json(data) await self.ws.send_json(data)
except Exception: return True
except ConnectionResetError:
self.is_connected = False 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): async def close(self):
if not self.is_connected: if not self.is_connected:
return True return True
await self.ws.close()
self.is_connected = False 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 return True
def __init__(self, app): def __init__(self, app):
@ -41,74 +83,292 @@ class SocketService(BaseService):
self.users = {} self.users = {}
self.subscriptions = {} self.subscriptions = {}
self.last_update = str(datetime.now()) self.last_update = str(datetime.now())
self._lock = asyncio.Lock()
async def user_availability_service(self): async def user_availability_service(self):
logger.info("User availability update service started.") logger.info("User availability update service started.")
logger.debug("Entering the main loop.")
while True: while True:
logger.info("Updating user availability...") try:
logger.debug("Initializing users_updated list.") sockets_copy = list(self.sockets)
users_updated = [] users_to_update = []
logger.debug("Iterating over sockets.") seen_user_uids = set()
for s in self.sockets: for s in sockets_copy:
logger.debug(f"Checking connection status for socket: {s}.") try:
if not s.is_connected: if not s or not s.is_connected:
logger.debug("Socket is not connected, continuing to next socket.")
continue continue
logger.debug(f"Checking if user {s.user} is already updated.") if not s.user or not s.user_uid:
if s.user not in users_updated: continue
logger.debug(f"Updating last_ping for user: {s.user}.") if s.user_uid in seen_user_uids:
s.user["last_ping"] = now() continue
logger.debug(f"Saving user {s.user} to the database.") seen_user_uids.add(s.user_uid)
await self.app.services.user.save(s.user) users_to_update.append(s.user_uid)
logger.debug(f"Adding user {s.user} to users_updated list.") except Exception as ex:
users_updated.append(s.user) logger.debug(f"Failed to check user availability: {safe_str(ex)}")
logger.info( for user_uid in users_to_update:
f"Updated user availability for {len(users_updated)} online users." try:
) if self.app and hasattr(self.app, "services") and self.app.services:
logger.debug("Sleeping for 60 seconds before the next update.") 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) await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("User availability service cancelled")
break
async def add(self, ws, user_uid): async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid)) 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) self.sockets.add(s)
s.user["last_ping"] = now() is_first_connection = False
await self.app.services.user.save(s.user) if user_uid not in self.users:
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] = set()
is_first_connection = True
elif len(self.users[user_uid]) == 0:
is_first_connection = True
self.users[user_uid].add(s) 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): async def subscribe(self, ws, channel_uid, user_uid):
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: if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set() self.subscriptions[channel_uid] = set()
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid)) self.subscriptions[channel_uid].add(user_uid)
self.subscriptions[channel_uid].add(s) 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): async def send_to_user(self, user_uid, message):
if not user_uid or message is None:
return 0
count = 0 count = 0
for s in list(self.users.get(user_uid, [])): 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): if await s.send_json(message):
count += 1 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 return count
async def broadcast(self, channel_uid, message): 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): async def _broadcast(self, channel_uid, message):
if not channel_uid or message is None:
return False
sent = 0 sent = 0
user_uids_to_send = set()
try: try:
async for user_uid in self.services.channel_member.get_user_uids( if self.services:
channel_uid 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 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: except Exception as ex:
print(ex, flush=True) logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
logger.info(f"Broadcasted a message to {sent} users.") 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 return True
except Exception as ex:
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): async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]: 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() await s.close()
logger.info(f"Removed socket for user {s.user['username']}") except Exception as ex:
self.sockets.remove(s) 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,6 +1,10 @@
from snek.system.service import BaseService # retoor <retoor@molodetz.nl>
import sqlite3 import sqlite3
from snek.system.service import BaseService
class StatisticsService(BaseService): class StatisticsService(BaseService):
def database(self): def database(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,10 @@
// Written by retoor@molodetz.nl // retoor <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
import { Schedule } from "./schedule.js"; import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js"; import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js"; import { Socket } from "./socket.js";
import { Njet } from "./njet.js"; import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient { export class RESTClient {
debug = false; debug = false;
@ -160,9 +154,10 @@ export class App extends EventHandler {
typeLock = null; typeLock = null;
typeListener = null; typeListener = null;
typeEventChannelUid = null; typeEventChannelUid = null;
_debug = false _debug = false;
presenceNotification = null;
async set_typing(channel_uid) { async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid; this.typeEventChannelUid = channel_uid;
} }
debug() { debug() {
this._debug = !this._debug; this._debug = !this._debug;
@ -173,18 +168,9 @@ export class App extends EventHandler {
this.is_pinging = true; this.is_pinging = true;
await this.rpc.ping(...args); await this.rpc.ping(...args);
this.is_pinging = false; this.is_pinging = false;
}
ntsh(times,message) {
if(!message)
message = "Nothing to see here!"
if(!times)
times=100
for(let x = 0; x < times; x++){
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
}
} }
async forcePing(...arg) { async forcePing(...arg) {
await this.rpc.ping(...args); await this.rpc.ping(...arg);
} }
starField = null starField = null
constructor() { constructor() {
@ -192,6 +178,7 @@ export class App extends EventHandler {
this.ws = new Socket(); this.ws = new Socket();
this.rpc = this.ws.client; this.rpc = this.ws.client;
this.audio = new NotificationAudio(500); this.audio = new NotificationAudio(500);
this.presenceNotification = new PresenceNotification(this.ws);
this.is_pinging = false; this.is_pinging = false;
this.ping_interval = setInterval(() => { this.ping_interval = setInterval(() => {
this.ping("active"); this.ping("active");
@ -202,7 +189,7 @@ export class App extends EventHandler {
this.rpc.set_typing(this.typeEventChannelUid); this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null; this.typeEventChannelUid = null;
} }
}); }, 1000);
const me = this; const me = this;
this.ws.addEventListener("connected", (data) => { this.ws.addEventListener("connected", (data) => {

View File

@ -66,11 +66,13 @@ header .logo {
} }
header nav a { header nav a {
color: #aaa; color: #888;
text-decoration: none; text-decoration: none;
margin-left: 15px; margin-left: 15px;
font-size: 1em; font-size: 1em;
transition: color 0.3s; padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
} }
.no-select { .no-select {
@ -82,6 +84,7 @@ header nav a {
header nav a:hover { header nav a:hover {
color: #fff; color: #fff;
background-color: rgba(255, 255, 255, 0.05);
} }
a { a {
@ -407,34 +410,69 @@ a {
width: 250px; width: 250px;
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-top: 10px; padding-top: 20px;
overflow-y: auto; overflow-y: auto;
grid-area: sidebar; grid-area: sidebar;
} }
.sidebar h2 { .sidebar h2 {
color: #f05a28; 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; 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 { .sidebar ul {
list-style: none; list-style: none;
}
.sidebar ul li {
margin-bottom: 15px; margin-bottom: 15px;
} }
.sidebar ul li {
margin-bottom: 4px;
}
.sidebar ul li a { .sidebar ul li a {
color: #ccc; color: #888;
text-decoration: none; text-decoration: none;
font-size: 1em; font-family: 'Courier New', monospace;
transition: color 0.3s; 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 { .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 { @keyframes glow {
@ -459,31 +497,40 @@ a {
header { header {
top: 0; top: 0;
left: 0; left: 0;
text-overflow: ellipsis;
width: 100%; width: 100%;
display: flex; display: flex !important;
flex-direction: column; flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px;
padding: 10px 12px;
}
.logo { header > * {
display: block; display: inline-block !important;
flex: 1; vertical-align: middle;
}
header .logo {
flex: 1 1 auto !important;
min-width: 0;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
}
h2 { header .logo h2 {
font-size: 14px; font-size: 14px;
} }
text-align: center; header nav {
display: none !important;
} }
nav { header nav-menu,
text-align: right; header channel-menu {
flex: 1; flex: 0 0 auto !important;
display: block;
width: 100%;
}
} }
/* /*
@ -549,12 +596,22 @@ dialog .dialog-actions {
} }
dialog .dialog-button { dialog .dialog-button {
padding: 8px 16px; padding: 10px 20px;
font-size: 0.95rem; font-family: 'Courier New', monospace;
border-radius: 8px; font-size: 14px;
border: none; font-weight: 500;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #e6e6e6;
cursor: pointer; 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 { dialog .dialog-button.primary {
background-color: #f05a28; background-color: #f05a28;
color: white; border-color: #f05a28;
color: #fff;
} }
dialog .dialog-button.primary:hover { dialog .dialog-button.primary:hover {
background-color: #f05a28; background-color: #e04924;
border-color: #e04924;
} }
dialog .dialog-button.secondary { dialog .dialog-button.secondary {
background-color: #f0a328; background-color: #2a2a2a;
color: #eee; border-color: #444;
color: #e6e6e6;
} }
dialog .dialog-button.secondary:hover { dialog .dialog-button.secondary:hover {
background-color: #f0b84c; background-color: #3a3a3a;
border-color: #555;
} }
dialog .dialog-button.primary:disabled, dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] { dialog .dialog-button.primary[aria-disabled="true"] {
/* slightly darker + lower saturation of the live colour */ background-color: #0a0a0a;
background-color: #70321e; /* muted burnt orange */ border-color: #222;
color: #bfbfbf; /* light grey text */ color: #555;
opacity: .55;
opacity: .55; /* unified fade */
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
} }
/* ---------- SECONDARY (yellow) ---------- */
dialog .dialog-button.secondary:disabled, dialog .dialog-button.secondary:disabled,
dialog .dialog-button.secondary[aria-disabled="true"] { dialog .dialog-button.secondary[aria-disabled="true"] {
background-color: #6c5619; /* muted mustard */ background-color: #0a0a0a;
color: #bfbfbf; border-color: #222;
color: #555;
opacity: .55; opacity: .55;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
@ -621,6 +680,119 @@ dialog .dialog-button:disabled:focus {
outline: none; 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 { .embed-url-link {
display: flex; display: flex;
flex-direction: column; 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 { app } from "./app.js";
import { NjetComponent,eventBus } from "./njet.js"; import { NjetComponent, eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.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 { class ChatInputComponent extends NjetComponent {
autoCompletions = { autoCompletions = {
@ -368,22 +374,16 @@ textToLeetAdvanced(text) {
this.fileUploadGrid = new FileUploadGrid(); this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid); this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none"; 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("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2"); this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea); this.appendChild(this.textarea);
this.ttsButton = document.createElement("stt-button");
this.snekSpeaker = document.createElement("snek-speaker"); this.sttButton = document.createElement("stt-button");
this.appendChild(this.snekSpeaker); this.ttsButton = document.createElement("tts-button");
this.ttsButton.addEventListener("click", (e) => {
this.snekSpeaker.enable()
});
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button"); this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid); this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => { this.uploadButton.addEventListener("upload", (e) => {
@ -398,10 +398,13 @@ textToLeetAdvanced(text) {
}); });
this.subscribe("file-uploading", (e) => { this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block"; this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none"; });
this.textarea.style.display = "none";
}) this.toolbarMenu = document.createElement("toolbar-menu");
this.appendChild(this.uploadButton); 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.textarea.addEventListener("blur", () => {
this.updateFromInput(this.value, true).then( this.updateFromInput(this.value, true).then(
@ -410,8 +413,6 @@ textToLeetAdvanced(text) {
}); });
this.subscribe("file-uploads-done", (data)=>{ this.subscribe("file-uploads-done", (data)=>{
this.textarea.style.display = "block";
this.uploadButton.style.display = "block";
this.fileUploadGrid.style.display = "none"; this.fileUploadGrid.style.display = "none";
let msg =data.reduce((message, file) => { let msg =data.reduce((message, file) => {
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`; return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
@ -504,7 +505,10 @@ textToLeetAdvanced(text) {
flagTyping() { flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date(); 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){ }else if(this._leetSpeakAdvanced){
value = this.textToLeetAdvanced(value); 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.value = "";
this.messageUid = null; this.messageUid = null;
this.queuedMessage = null; this.queuedMessage = null;
@ -526,6 +535,7 @@ textToLeetAdvanced(text) {
updateFromInput(value, isFinal = false) { updateFromInput(value, isFinal = false) {
log.debug("updateFromInput called", { valueLength: value?.length, isFinal, liveType: this.liveType, channelUid: this.channelUid });
this.value = value; this.value = value;
@ -533,13 +543,22 @@ textToLeetAdvanced(text) {
if (this.liveType && value[0] !== "/") { if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value); 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); this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid; return this.messageUid;
} }
} }
async sendMessage(channelUid, value, is_final) { 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 // 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. // 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 { app } from "./app.js";
import { EventHandler } from "./event-handler.js"; import { EventHandler } from "./event-handler.js";
@ -30,7 +32,7 @@ export class Container extends EventHandler{
} }
refresh(){ refresh(){
//this._fitAddon.fit(); //this._fitAddon.fit();
this.ws.send("\x0C"); this.ws.send(" ");
} }
toggle(){ toggle(){
this._container.classList.toggle("hidden") this._container.classList.toggle("hidden")
@ -110,4 +112,3 @@ export class Container extends EventHandler{
window.getContainer = function(){ window.getContainer = function(){
return new Container(app.channelUid) return new Container(app.channelUid)
}*/ }*/

View File

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

View File

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

View File

@ -1,33 +1,178 @@
// retoor <retoor@molodetz.nl>
export class EventHandler { export class EventHandler {
constructor() { constructor() {
this.subscribers = {}; this.subscribers = {};
this._maxListeners = 100;
this._warnOnMaxListeners = true;
} }
addEventListener(type, handler, { once = false } = {}) { addEventListener(type, handler, options = {}) {
if (!this.subscribers[type]) this.subscribers[type] = []; 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) { if (once) {
const originalHandler = handler; const originalHandler = handler;
handler = (...args) => { wrappedHandler = (...args) => {
try {
originalHandler(...args); originalHandler(...args);
this.removeEventListener(type, handler); } 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) { emit(type, ...data) {
if (this.subscribers[type]) if (!type || typeof type !== "string") {
this.subscribers[type].forEach((handler) => handler(...data)); 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) { removeEventListener(type, handler) {
if (!this.subscribers[type]) return; if (!type || typeof type !== "string") {
this.subscribers[type] = this.subscribers[type].filter( return false;
(h) => h !== handler }
); 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) { if (this.subscribers[type].length === 0) {
delete this.subscribers[type]; 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 // 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. // 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 { button {
width: var(--width); width: var(--width);
min-width: ${size}; min-width: ${size};
padding: 10px; padding: 10px 20px;
background-color: #f05a28; background-color: #1a1a1a;
border: none; border: 1px solid #333;
border-radius: 5px; border-radius: 4px;
color: white; color: #e6e6e6;
font-size: 1em; font-family: 'Courier New', monospace;
font-weight: bold; font-size: 14px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s; transition: all 0.2s ease;
border: 1px solid #f05a28;
} }
button:hover { 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; 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 */ /* A <file-browser> custom element that talks to /api/files */
import { NjetComponent } from "/njet.js"; import { NjetComponent } from "/njet.js";

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by 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. // 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,19 +78,24 @@ class GenericField extends HTMLElement {
input { input {
width: 90%; width: 90%;
padding: 10px; padding: 10px 12px;
margin: 10px 0; margin: 10px 0;
border: 1px solid #333; border: 1px solid #333;
border-radius: 5px; border-radius: 4px;
background-color: #1a1a1a; background-color: #0f0f0f;
color: #e6e6e6; 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 { &:focus {
outline: 2px solid #f05a28 !important; outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
} }
&::placeholder { &::placeholder {
color: #555;
transition: opacity 0.3s; transition: opacity 0.3s;
} }
@ -99,24 +106,38 @@ class GenericField extends HTMLElement {
button { button {
width: 50%; width: 50%;
padding: 10px; padding: 10px 20px;
background-color: #f05a28; background-color: #1a1a1a;
border: none; border: 1px solid #333;
float: right; float: right;
margin-top: 10px; margin-top: 10px;
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
border-radius: 5px; border-radius: 4px;
color: white; color: #e6e6e6;
font-size: 1em; font-family: 'Courier New', monospace;
font-weight: bold; font-size: 14px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s; transition: all 0.2s ease;
clear: both; clear: both;
} }
button:hover { 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; background-color: #e04924;
border-color: #e04924;
} }
a { a {
@ -133,17 +154,13 @@ class GenericField extends HTMLElement {
} }
.valid { .valid {
border: 1px solid green; border-color: #006400;
color: green; box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
font-size: 0.9em;
margin-top: 5px;
} }
.error { .error {
border: 3px solid red; border-color: #8b0000;
color: #d8000c; box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
font-size: 0.9em;
margin-top: 5px;
} }
@media (max-width: 500px) { @media (max-width: 500px) {

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by 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. // 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 // 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. // 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 // 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. // 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 // 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. // 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"; import { app } from "./app.js";
const LONG_TIME = 1000 * 60 * 20; const LONG_TIME = 1000 * 60 * 20;
@ -162,12 +157,10 @@ class MessageList extends HTMLElement {
threshold: 0, threshold: 0,
}); });
// End-of-messages marker
this.endOfMessages = document.createElement('div'); this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom'); this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages); this.prepend(this.endOfMessages);
// Observe existing children and index by uid
for (const c of this.children) { for (const c of this.children) {
this._observer.observe(c); this._observer.observe(c);
if (c instanceof MessageElement) { if (c instanceof MessageElement) {
@ -175,19 +168,30 @@ class MessageList extends HTMLElement {
} }
} }
// Wire up socket events this._updateMessageHandler = (data) => {
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) { if (this.messageMap.has(data.uid)) {
this.upsertMessage(data); 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); 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); 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() { connectedCallback() {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
if ( if (
@ -256,8 +260,8 @@ class MessageList extends HTMLElement {
} }
triggerGlow(uid, color) { triggerGlow(uid, color) {
if (!uid || !color) return; if (color && app.starField) app.starField.glowColor(color);
app.starField.glowColor(color); if (!uid) return;
let lastElement = null; let lastElement = null;
this.querySelectorAll('.avatar').forEach((el) => { this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a'); const anchor = el.closest('a');
@ -285,12 +289,6 @@ class MessageList extends HTMLElement {
upsertMessage(data) { upsertMessage(data) {
let message = this.messageMap.get(data.uid); 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){ if(message && !data.message){
message.parentElement?.removeChild(message); message.parentElement?.removeChild(message);
message = null; message = null;

View File

@ -1,3 +1,5 @@
// retoor <retoor@molodetz.nl>
// Written by 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. // 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