Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af62d24868 | |||
| 82ea496f0d | |||
| 70544cf488 | |||
| 1d0c835087 | |||
| 8f803b86d9 | |||
| d5bff8b855 | |||
| f129574b62 | |||
| bfec6f9d69 | |||
| 20543245f8 | |||
| 38f027a9fb | |||
| 1bc7bbe81e | |||
| da30590080 | |||
| a59bbc213a | |||
| 3689775efc | |||
| 6250645fa4 | |||
| 6099fc651c | |||
| 74074093dc | |||
| 56d0ef01fa | |||
| 163828e213 | |||
| 401e558011 | |||
| 572d9610f2 | |||
| 1d3444d665 | |||
| e0f54fb661 | |||
| 8bacd6aa3f | |||
| b710008dbe | |||
| 70a405b231 | |||
| bdc8f149dc | |||
| 2e886c7bc1 | |||
| 1eed22de5c | |||
| c23ce6085a | |||
| e798fd50a8 | |||
| 48b360bce1 | |||
| 0e083268c6 | |||
| 0ad8f6cb31 | |||
| f8d650567a | |||
| 8db6f39046 | |||
| e4ebd8b4fd | |||
| efcd10c3c0 | |||
| 2a9b883e1b | |||
| f770dcf2db | |||
| 2deb8a2069 | |||
| 87e19e3d02 | |||
|
|
31a754d264 |
194
CHANGELOG.md
Normal file
194
CHANGELOG.md
Normal 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)
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.0.0"
|
||||
version = "1.24.0"
|
||||
readme = "README.md"
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
@ -40,7 +40,8 @@ dependencies = [
|
||||
"pillow-heif",
|
||||
"IP2Location",
|
||||
"bleach",
|
||||
"sentry-sdk"
|
||||
"sentry-sdk",
|
||||
"bcrypt"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@ -51,3 +52,10 @@ where = ["src"] # <-- this changed
|
||||
|
||||
[project.scripts]
|
||||
snek = "snek.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-aiohttp"
|
||||
]
|
||||
|
||||
15
pytest.ini
Normal file
15
pytest.ini
Normal 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
55
review.md
Normal 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.)
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
"""
|
||||
MIT License
|
||||
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
from aiohttp import web
|
||||
from IPython import start_ipython
|
||||
from snek.shell import Shell
|
||||
|
||||
from snek.app import Application
|
||||
from snek.shell import Shell
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
@click.group()
|
||||
@ -111,9 +111,16 @@ def init(db_path, source):
|
||||
show_default=True,
|
||||
help="Database path for the application",
|
||||
)
|
||||
def serve(port, host, db_path):
|
||||
# init(db_path)
|
||||
# asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable debug logging",
|
||||
)
|
||||
def serve(port, host, db_path, debug):
|
||||
if debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.getLogger("snek").setLevel(logging.DEBUG)
|
||||
web.run_app(Application(db_path=f"sqlite:///{db_path}"), port=port, host=host)
|
||||
|
||||
|
||||
|
||||
163
src/snek/app.py
163
src/snek/app.py
@ -1,22 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
import ssl
|
||||
import uuid
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
import aiohttp_debugtoolbar
|
||||
|
||||
from snek import snode
|
||||
from snek.view.threads import ThreadsView
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from ipaddress import ip_address
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from ipaddress import ip_address
|
||||
|
||||
import IP2Location
|
||||
from aiohttp import web
|
||||
@ -29,6 +22,7 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
from app.app import Application as BaseApplication
|
||||
from jinja2 import FileSystemLoader
|
||||
|
||||
from snek import snode
|
||||
from snek.mapper import get_mappers
|
||||
from snek.service import get_services
|
||||
from snek.sgit import GitApplication
|
||||
@ -37,6 +31,7 @@ from snek.system import http
|
||||
from snek.system.cache import Cache
|
||||
from snek.system.markdown import MarkdownExtension
|
||||
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
||||
from snek.system.config import config
|
||||
from snek.system.profiler import profiler_handler
|
||||
from snek.system.template import (
|
||||
EmojiExtension,
|
||||
@ -51,6 +46,7 @@ from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView,
|
||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||
from snek.view.drive import DriveApiView, DriveView
|
||||
from snek.view.channel import ChannelDriveApiView
|
||||
from snek.view.container import ContainerView
|
||||
from snek.view.index import IndexView
|
||||
from snek.view.login import LoginView
|
||||
from snek.view.logout import LogoutView
|
||||
@ -59,7 +55,7 @@ from snek.view.register import RegisterView
|
||||
from snek.view.repository import RepositoryView
|
||||
from snek.view.rpc import RPCView
|
||||
from snek.view.search_user import SearchUserView
|
||||
from snek.view.container import ContainerView
|
||||
from snek.view.threads import ThreadsView
|
||||
from snek.view.settings.containers import (
|
||||
ContainersCreateView,
|
||||
ContainersDeleteView,
|
||||
@ -74,6 +70,13 @@ from snek.view.settings.repositories import (
|
||||
RepositoriesIndexView,
|
||||
RepositoriesUpdateView,
|
||||
)
|
||||
from snek.view.settings.profile_pages import (
|
||||
ProfilePagesView,
|
||||
ProfilePageCreateView,
|
||||
ProfilePageEditView,
|
||||
ProfilePageDeleteView,
|
||||
)
|
||||
from snek.view.profile_page import ProfilePageView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
@ -82,9 +85,12 @@ from snek.view.user import UserView
|
||||
from snek.view.web import WebView
|
||||
from snek.webdav import WebdavApplication
|
||||
from snek.forum import setup_forum
|
||||
from snek.system.template import whitelist_attributes
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
from snek.system.template import whitelist_attributes
|
||||
|
||||
|
||||
@web.middleware
|
||||
@ -97,21 +103,22 @@ async def session_middleware(request, handler):
|
||||
@web.middleware
|
||||
async def ip2location_middleware(request, handler):
|
||||
response = await handler(request)
|
||||
return response
|
||||
ip = request.headers.get("X-Forwarded-For", request.remote)
|
||||
ipaddress = ip_address(ip)
|
||||
if ipaddress.is_private:
|
||||
try:
|
||||
ipaddr = ip_address(ip)
|
||||
if ipaddr.is_private:
|
||||
return response
|
||||
except ValueError:
|
||||
return response
|
||||
if not request.app.session.get("uid"):
|
||||
if not request.session.get("uid"):
|
||||
return response
|
||||
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
|
||||
user = await request.app.services.user.get(uid=request.session.get("uid"))
|
||||
if not user:
|
||||
return response
|
||||
location = request.app.ip2location.get(ip)
|
||||
user["city"]
|
||||
location = request.app.ip2location.get_all(ip)
|
||||
if user["city"] != location.city:
|
||||
user["country_long"] = location.country
|
||||
user["country_short"] = locaion.country_short
|
||||
user["country_long"] = location.country_long
|
||||
user["country_short"] = location.country_short
|
||||
user["city"] = location.city
|
||||
user["region"] = location.region
|
||||
user["latitude"] = location.latitude
|
||||
@ -124,23 +131,35 @@ async def ip2location_middleware(request, handler):
|
||||
@web.middleware
|
||||
async def trailing_slash_middleware(request, handler):
|
||||
if request.path and not request.path.endswith("/"):
|
||||
# Redirect to the same path with a trailing slash
|
||||
raise web.HTTPFound(request.path + "/")
|
||||
return await handler(request)
|
||||
|
||||
|
||||
class Application(BaseApplication):
|
||||
def __init__(self, *args, **kwargs):
|
||||
async def create_default_forum(self, app):
|
||||
forums = [f async for f in self.services.forum.find(is_active=True)]
|
||||
if not forums:
|
||||
# Find admin user to be the creator
|
||||
admin_user = await self.services.user.get(is_admin=True)
|
||||
if admin_user:
|
||||
await self.services.forum.create_forum(
|
||||
name="General Discussion",
|
||||
description="A place for general discussion.",
|
||||
created_by_uid=admin_user["uid"],
|
||||
)
|
||||
|
||||
def __init__(self, *args, db_connection=None, **kwargs):
|
||||
middlewares = [
|
||||
cors_middleware,
|
||||
csp_middleware,
|
||||
]
|
||||
self._test_db = db_connection
|
||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
|
||||
super().__init__(
|
||||
middlewares=middlewares,
|
||||
template_path=self.template_path,
|
||||
client_max_size=1024 * 1024 * 1024 * 5 * args,
|
||||
client_max_size=1024 * 1024 * 1024 * 5,
|
||||
**kwargs,
|
||||
)
|
||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||
@ -161,11 +180,12 @@ class Application(BaseApplication):
|
||||
self.sync_service = None
|
||||
self.executor = None
|
||||
self.cache = Cache(self)
|
||||
self.config = config
|
||||
self.services = get_services(app=self)
|
||||
self.mappers = get_mappers(app=self)
|
||||
self.broadcast_service = None
|
||||
self.user_availability_service_task = None
|
||||
|
||||
|
||||
self.setup_router()
|
||||
base_path = pathlib.Path(__file__).parent
|
||||
self.ip2location = IP2Location.IP2Location(
|
||||
@ -175,7 +195,17 @@ class Application(BaseApplication):
|
||||
self.on_startup.append(self.start_user_availability_service)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
self.on_startup.append(self.prepare_database)
|
||||
|
||||
self.on_startup.append(self.create_default_forum)
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
if self._test_db is not None:
|
||||
return self._test_db
|
||||
return self._db
|
||||
|
||||
@db.setter
|
||||
def db(self, value):
|
||||
self._db = value
|
||||
|
||||
@property
|
||||
def uptime_seconds(self):
|
||||
@ -217,13 +247,8 @@ class Application(BaseApplication):
|
||||
asyncio.create_task(app.ssh_server.wait_closed())
|
||||
|
||||
async def prepare_asyncio(self, app):
|
||||
# app.loop = asyncio.get_running_loop()
|
||||
app.executor = ThreadPoolExecutor(max_workers=200)
|
||||
app.loop.set_default_executor(self.executor)
|
||||
#for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
#app.loop.add_signal_handler(
|
||||
# sig, lambda: asyncio.create_task(self.services.container.shutdown())
|
||||
#)
|
||||
|
||||
async def create_task(self, task):
|
||||
await self.tasks.put(task)
|
||||
@ -236,9 +261,8 @@ class Application(BaseApplication):
|
||||
await task
|
||||
self.tasks.task_done()
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
logger.error(f"Task runner error: {ex}")
|
||||
self.db.commit()
|
||||
|
||||
|
||||
async def prepare_database(self, app):
|
||||
self.db.query("PRAGMA journal_mode=WAL")
|
||||
@ -251,8 +275,8 @@ class Application(BaseApplication):
|
||||
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
|
||||
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
|
||||
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
|
||||
except:
|
||||
pass
|
||||
except Exception as ex:
|
||||
logger.warning(f"Index creation error: {ex}")
|
||||
|
||||
await self.services.drive.prepare_all()
|
||||
self.loop.create_task(self.task_runner())
|
||||
@ -285,9 +309,6 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/login.json", LoginView)
|
||||
self.router.add_view("/register.html", RegisterView)
|
||||
self.router.add_view("/register.json", RegisterView)
|
||||
# self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
## self.router.add_view("/drive.bin", UploadView)
|
||||
# self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
||||
self.router.add_view("/search-user.html", SearchUserView)
|
||||
self.router.add_view("/search-user.json", SearchUserView)
|
||||
self.router.add_view("/avatar/{uid}.svg", AvatarView)
|
||||
@ -295,27 +316,22 @@ class Application(BaseApplication):
|
||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||
self.router.add_get("/rpc.ws", RPCView)
|
||||
self.router.add_get("/c/{channel:.*}", ChannelView)
|
||||
#self.router.add_view(
|
||||
# "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
|
||||
#)
|
||||
#self.router.add_view(
|
||||
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView
|
||||
#)
|
||||
self.router.add_view(
|
||||
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
|
||||
)
|
||||
self.router.add_view(
|
||||
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
|
||||
)#
|
||||
)
|
||||
self.router.add_view("/channel/{channel}.html", WebView)
|
||||
self.router.add_view("/threads.html", ThreadsView)
|
||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||
self.router.add_view("/terminal.html", TerminalView)
|
||||
#self.router.add_view("/drive.json", DriveApiView)
|
||||
#self.router.add_view("/drive.html", DriveView)
|
||||
#self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
self.router.add_view("/drive.json", DriveApiView)
|
||||
self.router.add_view("/drive.html", DriveView)
|
||||
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
self.router.add_view("/stats.json", StatsView)
|
||||
self.router.add_view("/user/{user}.html", UserView)
|
||||
self.router.add_view("/user/{user_uid}/{slug}.html", ProfilePageView)
|
||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||
self.router.add_view(
|
||||
"/repository/{username}/{repository}/{path:.*}", RepositoryView
|
||||
@ -332,6 +348,14 @@ class Application(BaseApplication):
|
||||
"/settings/repositories/repository/{name}/delete.html",
|
||||
RepositoriesDeleteView,
|
||||
)
|
||||
self.router.add_view("/settings/profile_pages/index.html", ProfilePagesView)
|
||||
self.router.add_view("/settings/profile_pages/create.html", ProfilePageCreateView)
|
||||
self.router.add_view(
|
||||
"/settings/profile_pages/{page_uid}/edit.html", ProfilePageEditView
|
||||
)
|
||||
self.router.add_view(
|
||||
"/settings/profile_pages/{page_uid}/delete.html", ProfilePageDeleteView
|
||||
)
|
||||
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
|
||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||
self.router.add_view(
|
||||
@ -346,10 +370,8 @@ class Application(BaseApplication):
|
||||
self.add_subapp("/webdav", self.webdav)
|
||||
self.add_subapp("/git", self.git)
|
||||
setup_forum(self)
|
||||
# self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
async def handle_test(self, request):
|
||||
|
||||
return await whitelist_attributes(
|
||||
self.render_template("test.html", request, context={"name": "retoor"})
|
||||
)
|
||||
@ -366,14 +388,13 @@ class Application(BaseApplication):
|
||||
body=path.read_bytes(), headers={"Content-Type": "image/png"}
|
||||
)
|
||||
|
||||
# @time_cache_async(60)
|
||||
async def render_template(self, template, request, context=None):
|
||||
start_time = time.perf_counter()
|
||||
|
||||
channels = []
|
||||
if not context:
|
||||
context = {}
|
||||
context["rid"] = str(uuid.uuid4())
|
||||
context["config"] = self.config
|
||||
if request.session.get("uid"):
|
||||
async for subscribed_channel in self.services.channel_member.find(
|
||||
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
||||
@ -411,31 +432,25 @@ class Application(BaseApplication):
|
||||
request.session.get("uid")
|
||||
)
|
||||
|
||||
self.template_path.joinpath(template)
|
||||
|
||||
await self.services.user.get_template_path(request.session.get("uid"))
|
||||
|
||||
self.original_loader = self.jinja2_env.loader
|
||||
|
||||
self.jinja2_env.loader = await self.get_user_template_loader(
|
||||
request.session.get("uid")
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
context["nonce"] = request['csp_nonce']
|
||||
except:
|
||||
context['nonce'] = '?'
|
||||
context["nonce"] = request["csp_nonce"]
|
||||
except KeyError:
|
||||
context["nonce"] = "?"
|
||||
|
||||
rendered = await super().render_template(template, request, context)
|
||||
|
||||
self.jinja2_env.loader = self.original_loader
|
||||
|
||||
end_time = time.perf_counter()
|
||||
print(f"render_template took {end_time - start_time:.4f} seconds")
|
||||
|
||||
# rendered.text = whitelist_attributes(rendered.text)
|
||||
# rendered.headers['Content-Lenght'] = len(rendered.text)
|
||||
logger.debug(f"render_template took {end_time - start_time:.4f} seconds")
|
||||
return rendered
|
||||
|
||||
async def static_handler(self, request):
|
||||
file_name = request.match_info.get("filename", "")
|
||||
|
||||
@ -473,22 +488,21 @@ class Application(BaseApplication):
|
||||
|
||||
template_paths.append(self.template_path)
|
||||
return FileSystemLoader(template_paths)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def no_save(self):
|
||||
stats = {
|
||||
'count': 0
|
||||
}
|
||||
stats = {"count": 0}
|
||||
|
||||
async def patched_save(*args, **kwargs):
|
||||
await self.cache.set(args[0]["uid"], args[0])
|
||||
stats['count'] = stats['count'] + 1
|
||||
print(f"save is ignored {stats['count']} times")
|
||||
stats["count"] = stats["count"] + 1
|
||||
logger.debug(f"save is ignored {stats['count']} times")
|
||||
return args[0]
|
||||
save_original = self.services.channel_message.mapper.save
|
||||
self.services.channel_message.mapper.save = patched_save
|
||||
raised_exception = None
|
||||
try:
|
||||
yield
|
||||
yield
|
||||
except Exception as ex:
|
||||
raised_exception = ex
|
||||
finally:
|
||||
@ -497,7 +511,6 @@ class Application(BaseApplication):
|
||||
raise raised_exception
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
#aiohttp_debugtoolbar.setup(app)
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
@ -7,7 +9,6 @@ class LoadBalancer:
|
||||
self.backend_ports = backend_ports
|
||||
self.backend_processes = []
|
||||
self.client_counts = [0] * len(backend_ports)
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def start_backend_servers(self, port, workers):
|
||||
for x in range(workers):
|
||||
@ -27,10 +28,9 @@ class LoadBalancer:
|
||||
)
|
||||
|
||||
async def handle_client(self, reader, writer):
|
||||
async with self.lock:
|
||||
min_clients = min(self.client_counts)
|
||||
server_index = self.client_counts.index(min_clients)
|
||||
self.client_counts[server_index] += 1
|
||||
min_clients = min(self.client_counts)
|
||||
server_index = self.client_counts.index(min_clients)
|
||||
self.client_counts[server_index] += 1
|
||||
backend = ("127.0.0.1", self.backend_ports[server_index])
|
||||
try:
|
||||
backend_reader, backend_writer = await asyncio.open_connection(*backend)
|
||||
@ -55,8 +55,7 @@ class LoadBalancer:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
writer.close()
|
||||
async with self.lock:
|
||||
self.client_counts[server_index] -= 1
|
||||
self.client_counts[server_index] -= 1
|
||||
|
||||
async def monitor(self):
|
||||
while True:
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pathlib
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
|
||||
from snek.app import app
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
# forum_app.py
|
||||
import aiohttp.web
|
||||
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
|
||||
@ -78,29 +80,7 @@ class ForumApplication(aiohttp.web.Application):
|
||||
|
||||
async def serve_forum_html(self, request):
|
||||
"""Serve the forum HTML with the web component"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Forum</title>
|
||||
<script type="module" src="/forum/static/snek-forum.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<snek-forum></snek-forum>
|
||||
</body>
|
||||
</html>"""
|
||||
return await self.parent.render_template("forum.html", request)
|
||||
|
||||
|
||||
#return aiohttp.web.Response(text=html, content_type="text/html")
|
||||
|
||||
|
||||
# Integration with main app
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.app import app
|
||||
|
||||
application = app
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import functools
|
||||
|
||||
from snek.mapper.channel import ChannelMapper
|
||||
@ -12,6 +14,7 @@ from snek.mapper.push import PushMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.profile_page import ProfilePageMapper
|
||||
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
@ -36,6 +39,7 @@ def get_mappers(app=None):
|
||||
"thread": ThreadMapper(app=app),
|
||||
"post": PostMapper(app=app),
|
||||
"post_like": PostLikeMapper(app=app),
|
||||
"profile_page": ProfilePageMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.channel import ChannelModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.channel_attachment import ChannelAttachmentModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.channel_member import ChannelMemberModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.container import Container
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.drive import DriveModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.drive_item import DriveItemModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
# mapper/forum.py
|
||||
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.notification import NotificationModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
11
src/snek/mapper/profile_page.py
Normal file
11
src/snek/mapper/profile_page.py
Normal 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
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.push_registration import PushRegistrationModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserMapper(BaseMapper):
|
||||
table_name = "user"
|
||||
@ -16,5 +22,5 @@ class UserMapper(BaseMapper):
|
||||
)
|
||||
]
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
logger.warning(f"Failed to get admin uids: {ex}")
|
||||
return []
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.user_property import UserPropertyModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import functools
|
||||
|
||||
from snek.model.channel import ChannelModel
|
||||
@ -44,5 +46,3 @@ def get_models():
|
||||
|
||||
def get_model(name):
|
||||
return get_models()[name]
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
@ -26,9 +28,8 @@ class ChannelModel(BaseModel):
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
|
||||
{"channel_uid": self["uid"]},
|
||||
):
|
||||
|
||||
return await self.app.services.channel_message.get(uid=model["uid"])
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from snek.model.user import UserModel
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import mimetypes
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
# models/forum.py
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
14
src/snek/model/profile_page.py
Normal file
14
src/snek/model/profile_page.py
Normal 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)
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
@ -7,4 +9,6 @@ class RepositoryModel(BaseModel):
|
||||
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
|
||||
description = ModelField(name="description", required=False, kind=str)
|
||||
|
||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class UserPropertyModel(BaseModel):
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
value = ModelField(name="path", required=True, kind=str)
|
||||
value = ModelField(name="value", required=True, kind=str)
|
||||
|
||||
@ -1,39 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import dataset
|
||||
import dataset.util
|
||||
import traceback
|
||||
import socket
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
|
||||
|
||||
class DatasetMethod:
|
||||
def __init__(self, dt, name):
|
||||
self.dt = dt
|
||||
self.name = name
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self.dt.ds.call(
|
||||
self.dt.name,
|
||||
self.name,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class DatasetTable:
|
||||
|
||||
def __init__(self, ds, name):
|
||||
self.ds = ds
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, name):
|
||||
return DatasetMethod(self, name)
|
||||
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WebSocketClient2:
|
||||
@ -43,200 +19,40 @@ class WebSocketClient2:
|
||||
self.websocket = None
|
||||
self.receive_queue = asyncio.Queue()
|
||||
|
||||
# Schedule connection setup
|
||||
if self.loop.is_running():
|
||||
# Schedule connect in the existing loop
|
||||
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
|
||||
else:
|
||||
# If loop isn't running, connect synchronously
|
||||
self.loop.run_until_complete(self._connect())
|
||||
|
||||
async def _connect(self):
|
||||
self.websocket = await websockets.connect(self.uri)
|
||||
# Start listening for messages
|
||||
asyncio.create_task(self._receive_loop())
|
||||
|
||||
async def _receive_loop(self):
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
await self.receive_queue.put(message)
|
||||
except Exception:
|
||||
pass # Handle exceptions as needed
|
||||
|
||||
def send(self, message: str):
|
||||
if self.loop.is_running():
|
||||
# Schedule send in the existing loop
|
||||
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
|
||||
else:
|
||||
# If loop isn't running, run directly
|
||||
self.loop.run_until_complete(self.websocket.send(message))
|
||||
|
||||
def receive(self):
|
||||
# Wait for a message synchronously
|
||||
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
|
||||
return future.result()
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
if self.websocket:
|
||||
if self.loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
|
||||
else:
|
||||
self.loop.run_until_complete(self.websocket.close())
|
||||
pass
|
||||
|
||||
|
||||
import websockets
|
||||
|
||||
class DatasetWrapper(object):
|
||||
|
||||
class DatasetWrapper:
|
||||
def __init__(self):
|
||||
self.ws = WebSocketClient()
|
||||
|
||||
def begin(self):
|
||||
self.call(None, 'begin')
|
||||
pass
|
||||
|
||||
def commit(self):
|
||||
self.call(None, 'commit')
|
||||
|
||||
def __getitem__(self, name):
|
||||
return DatasetTable(self, name)
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self.call(None, 'query', *args, **kwargs)
|
||||
|
||||
def call(self, table, method, *args, **kwargs):
|
||||
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
|
||||
#if method in ['find','find_one']:
|
||||
payload["call_uid"] = str(uuid.uuid4())
|
||||
self.ws.write(json.dumps(payload))
|
||||
if payload["call_uid"]:
|
||||
response = self.ws.read()
|
||||
return json.loads(response)['result']
|
||||
return True
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DatasetWebSocketView:
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
self.db = dataset.connect('sqlite:///snek.db')
|
||||
self.setattr(self, "db", self.get)
|
||||
self.setattr(self, "db", self.set)
|
||||
)
|
||||
super()
|
||||
|
||||
|
||||
def format_result(self, result):
|
||||
|
||||
try:
|
||||
return dict(result)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
return [dict(row) for row in result]
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
pass
|
||||
|
||||
async def send_str(self, msg):
|
||||
return await self.ws.send_str(msg)
|
||||
pass
|
||||
|
||||
def get(self, key):
|
||||
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
|
||||
pass
|
||||
|
||||
def set(self, key, value):
|
||||
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
|
||||
pass
|
||||
|
||||
|
||||
|
||||
async def handle(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.ws = ws
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
call_uid = data.get("call_uid")
|
||||
method = data.get("method")
|
||||
table_name = data.get("table")
|
||||
args = data.get("args", {})
|
||||
kwargs = data.get("kwargs", {})
|
||||
|
||||
|
||||
function = getattr(self.db, method, None)
|
||||
if table_name:
|
||||
function = getattr(self.db[table_name], method, None)
|
||||
|
||||
print(method, table_name, args, kwargs,flush=True)
|
||||
|
||||
if function:
|
||||
response = {}
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
print(result)
|
||||
response['result'] = self.format_result(result)
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = True
|
||||
except Exception as e:
|
||||
response["call_uid"] = call_uid
|
||||
response["success"] = False
|
||||
response["error"] = str(e)
|
||||
response["traceback"] = traceback.format_exc()
|
||||
|
||||
if call_uid:
|
||||
await self.send_str(json.dumps(response,default=str))
|
||||
else:
|
||||
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
|
||||
except Exception as e:
|
||||
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print('ws connection closed with exception %s' % ws.exception())
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = web.Application()
|
||||
view = DatasetWebSocketView()
|
||||
app.router.add_get('/db', view.handle)
|
||||
|
||||
async def run_server():
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, 'localhost', 3131)
|
||||
await site.start()
|
||||
|
||||
print("Server started at http://localhost:8080")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def client():
|
||||
print("x")
|
||||
d = DatasetWrapper()
|
||||
print("y")
|
||||
|
||||
for x in range(100):
|
||||
for x in range(100):
|
||||
if d['test'].insert({"name": "test", "number":x}):
|
||||
print(".",end="",flush=True)
|
||||
print("")
|
||||
print(d['test'].find_one(name="test", order_by="-number"))
|
||||
|
||||
print("DONE")
|
||||
|
||||
|
||||
|
||||
import time
|
||||
async def main():
|
||||
await run_server()
|
||||
|
||||
import sys
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1] == 'server':
|
||||
asyncio.run(main())
|
||||
if sys.argv[1] == 'client':
|
||||
asyncio.run(client())
|
||||
pass
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
|
||||
@ -1,32 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS http_access (
|
||||
id INTEGER NOT NULL,
|
||||
created TEXT,
|
||||
path TEXT,
|
||||
duration FLOAT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
CREATE TABLE user (
|
||||
id INTEGER NOT NULL,
|
||||
city TEXT,
|
||||
color TEXT,
|
||||
country_long TEXT,
|
||||
country_short TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
email TEXT,
|
||||
ip TEXT,
|
||||
is_admin TEXT,
|
||||
last_ping TEXT,
|
||||
latitude TEXT,
|
||||
longitude TEXT,
|
||||
nick TEXT,
|
||||
password TEXT,
|
||||
region TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
username TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel (
|
||||
CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
|
||||
CREATE TABLE channel (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
created_by_uid TEXT,
|
||||
deleted_at TEXT,
|
||||
description TEXT,
|
||||
history_start TEXT,
|
||||
"index" BIGINT,
|
||||
is_listed BOOLEAN,
|
||||
is_private BOOLEAN,
|
||||
@ -37,8 +38,8 @@ CREATE TABLE IF NOT EXISTS channel (
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel_member (
|
||||
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||
CREATE TABLE channel_member (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
@ -54,28 +55,31 @@ CREATE TABLE IF NOT EXISTS channel_member (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
|
||||
CREATE TABLE IF NOT EXISTS broadcast (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_message (
|
||||
CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
|
||||
CREATE TABLE channel_message (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
html TEXT,
|
||||
is_final BOOLEAN,
|
||||
message TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||
CREATE INDEX ix_channel_message_acb69f257bb37684 ON channel_message (is_final, user_uid, channel_uid);
|
||||
CREATE INDEX ix_channel_message_c6c4cddf281df93d ON channel_message (deleted_at);
|
||||
CREATE TABLE kv (
|
||||
id INTEGER NOT NULL,
|
||||
"key" TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_kv_a62f2225bf70bfac ON kv ("key");
|
||||
CREATE TABLE notification (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
@ -88,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||
CREATE TABLE IF NOT EXISTS repository (
|
||||
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||
CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
|
||||
CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
|
||||
CREATE TABLE drive (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
name TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_drive_e2577dd78b54fe28 ON drive (uid);
|
||||
CREATE TABLE push_registration (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
endpoint TEXT,
|
||||
key_auth TEXT,
|
||||
key_p256dh TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_push_registration_e2577dd78b54fe28 ON push_registration (uid);
|
||||
CREATE TABLE repository (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
description TEXT,
|
||||
is_private BIGINT,
|
||||
name TEXT,
|
||||
uid TEXT,
|
||||
@ -100,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
|
||||
CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
|
||||
CREATE TABLE profile_page (
|
||||
id INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
is_published BOOLEAN,
|
||||
order_index BIGINT,
|
||||
slug TEXT,
|
||||
title TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import functools
|
||||
|
||||
from snek.service.channel import ChannelService
|
||||
@ -19,6 +21,7 @@ from snek.service.util import UtilService
|
||||
from snek.system.object import Object
|
||||
from snek.service.statistics import StatisticsService
|
||||
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
|
||||
from snek.service.profile_page import ProfilePageService
|
||||
_service_registry = {}
|
||||
|
||||
def register_service(name, service_cls):
|
||||
@ -40,7 +43,7 @@ def get_services(app):
|
||||
def get_service(name, app=None):
|
||||
return get_services(app=app)[name]
|
||||
|
||||
# Registering all services
|
||||
|
||||
register_service("user", UserService)
|
||||
register_service("channel_member", ChannelMemberService)
|
||||
register_service("channel", ChannelService)
|
||||
@ -62,4 +65,5 @@ register_service("forum", ForumService)
|
||||
register_service("thread", ThreadService)
|
||||
register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
register_service("profile_page", ProfilePageService)
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
|
||||
@ -13,7 +15,7 @@ class ChannelService(BaseService):
|
||||
if not folder.exists():
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
except:
|
||||
except OSError:
|
||||
pass
|
||||
return folder
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import mimetypes
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
@ -8,11 +11,12 @@ class ChannelMemberService(BaseService):
|
||||
async def mark_as_read(self, channel_uid, user_uid):
|
||||
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
|
||||
channel_member["new_count"] = 0
|
||||
channel_member["last_read_at"] = now()
|
||||
return await self.save(channel_member)
|
||||
|
||||
async def get_user_uids(self, channel_uid):
|
||||
async for model in self.mapper.query(
|
||||
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
|
||||
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid AND deleted_at IS NULL AND is_banned = 0",
|
||||
{"channel_uid": channel_uid},
|
||||
):
|
||||
yield model["user_uid"]
|
||||
@ -56,7 +60,6 @@ class ChannelMemberService(BaseService):
|
||||
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
|
||||
{"from_user": from_user, "to_user": to_user},
|
||||
):
|
||||
|
||||
return model
|
||||
|
||||
async def get_other_dm_user(self, channel_uid, user_uid):
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.template import sanitize_html
|
||||
import time
|
||||
import asyncio
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
global jinja2_env
|
||||
import pathlib
|
||||
logger = logging.getLogger(__name__)
|
||||
jinja2_env = None
|
||||
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
|
||||
|
||||
|
||||
def render(context):
|
||||
template =jinja2_env.get_template("message.html")
|
||||
template = jinja2_env.get_template("message.html")
|
||||
return sanitize_html(template.render(**context))
|
||||
|
||||
|
||||
@ -27,10 +30,11 @@ class ChannelMessageService(BaseService):
|
||||
global jinja2_env
|
||||
jinja2_env = self.app.jinja2_env
|
||||
self._max_workers = 1
|
||||
|
||||
def get_or_create_executor(self, uid):
|
||||
if not uid in self._executor_pools:
|
||||
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=self._max_workers)
|
||||
print("Executors available", len(self._executor_pools))
|
||||
logger.debug(f"Executors available: {len(self._executor_pools)}")
|
||||
return self._executor_pools[uid]
|
||||
|
||||
def delete_executor(self, uid):
|
||||
@ -39,34 +43,6 @@ class ChannelMessageService(BaseService):
|
||||
del self._executor_pools[uid]
|
||||
|
||||
async def maintenance(self):
|
||||
args = {}
|
||||
|
||||
|
||||
return
|
||||
for message in self.mapper.db["channel_message"].find():
|
||||
print(message)
|
||||
try:
|
||||
message = await self.get(uid=message["uid"])
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"uid": message["uid"],
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
if html != message["html"]:
|
||||
print("Reredefined message", message["uid"])
|
||||
|
||||
except Exception as ex:
|
||||
time.sleep(0.1)
|
||||
print(ex, flush=True)
|
||||
|
||||
|
||||
while True:
|
||||
changed = 0
|
||||
async for message in self.find(is_final=False):
|
||||
@ -81,6 +57,7 @@ class ChannelMessageService(BaseService):
|
||||
break
|
||||
|
||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||
logger.info(f"create: channel_uid={channel_uid}, user_uid={user_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
|
||||
model = await self.new()
|
||||
|
||||
model["channel_uid"] = channel_uid
|
||||
@ -93,6 +70,9 @@ class ChannelMessageService(BaseService):
|
||||
record = model.record
|
||||
context.update(record)
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
if not user:
|
||||
logger.error(f"create: user not found user_uid={user_uid}")
|
||||
raise Exception("User not found")
|
||||
context.update(
|
||||
{
|
||||
"user_uid": user["uid"],
|
||||
@ -103,15 +83,13 @@ class ChannelMessageService(BaseService):
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
|
||||
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
logger.debug(f"create: rendering html for message uid={model['uid']}")
|
||||
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
|
||||
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
logger.error(f"create: html rendering failed: {ex}")
|
||||
|
||||
logger.debug(f"create: saving message uid={model['uid']}")
|
||||
if await super().save(model):
|
||||
if not self._configured_indexes:
|
||||
if not self.mapper.db["channel_message"].has_index(
|
||||
@ -129,7 +107,9 @@ class ChannelMessageService(BaseService):
|
||||
self._configured_indexes = True
|
||||
if model['is_final']:
|
||||
self.delete_executor(model['uid'])
|
||||
logger.info(f"create: message created successfully uid={model['uid']}, channel={channel_uid}")
|
||||
return model
|
||||
logger.error(f"create: failed to save message channel={channel_uid}, errors={model.errors}")
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
async def to_extended_dict(self, message):
|
||||
@ -154,9 +134,13 @@ class ChannelMessageService(BaseService):
|
||||
}
|
||||
|
||||
async def save(self, model):
|
||||
logger.debug(f"save: starting for uid={model['uid']}, is_final={model['is_final']}")
|
||||
context = {}
|
||||
context.update(model.record)
|
||||
user = await self.app.services.user.get(model["user_uid"])
|
||||
if not user:
|
||||
logger.error(f"save: user not found user_uid={model['user_uid']}")
|
||||
return False
|
||||
context.update(
|
||||
{
|
||||
"user_uid": user["uid"],
|
||||
@ -165,12 +149,16 @@ class ChannelMessageService(BaseService):
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
loop = asyncio.get_event_loop()
|
||||
logger.debug(f"save: rendering html for uid={model['uid']}")
|
||||
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context)
|
||||
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
|
||||
|
||||
result = await super().save(model)
|
||||
if result:
|
||||
logger.debug(f"save: message saved successfully uid={model['uid']}")
|
||||
else:
|
||||
logger.warning(f"save: failed to save message uid={model['uid']}")
|
||||
if model['is_final']:
|
||||
self.delete_executor(model['uid'])
|
||||
return result
|
||||
@ -219,6 +207,6 @@ class ChannelMessageService(BaseService):
|
||||
results.append(model)
|
||||
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
logger.error(f"offset query failed: {ex}")
|
||||
results.sort(key=lambda x: x["created_at"])
|
||||
return results
|
||||
|
||||
@ -1,17 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatService(BaseService):
|
||||
|
||||
async def finalize(self, message_uid):
|
||||
logger.info(f"finalize: starting for message_uid={message_uid}")
|
||||
channel_message = await self.services.channel_message.get(uid=message_uid)
|
||||
if not channel_message:
|
||||
logger.warning(f"finalize: message not found uid={message_uid}")
|
||||
return
|
||||
channel_message["is_final"] = True
|
||||
await self.services.channel_message.save(channel_message)
|
||||
logger.debug(f"finalize: message marked as final uid={message_uid}")
|
||||
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
|
||||
channel["last_message_on"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
|
||||
await self.services.socket.broadcast(
|
||||
channel["uid"],
|
||||
{
|
||||
@ -28,18 +40,23 @@ class ChatService(BaseService):
|
||||
"is_final": channel_message["is_final"],
|
||||
},
|
||||
)
|
||||
logger.info(f"finalize: completed for message_uid={message_uid}, channel={channel['uid']}")
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(message_uid)
|
||||
)
|
||||
|
||||
async def send(self, user_uid, channel_uid, message, is_final=True):
|
||||
logger.info(f"send: user_uid={user_uid}, channel_uid={channel_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
logger.error(f"send: channel not found channel_uid={channel_uid}")
|
||||
raise Exception("Channel not found.")
|
||||
logger.debug(f"send: checking for existing non-final message in channel={channel_uid}")
|
||||
channel_message = await self.services.channel_message.get(
|
||||
channel_uid=channel_uid,user_uid=user_uid, is_final=False
|
||||
)
|
||||
if channel_message:
|
||||
logger.debug(f"send: updating existing message uid={channel_message['uid']}")
|
||||
channel_message["message"] = message
|
||||
channel_message["is_final"] = is_final
|
||||
if not channel_message["is_final"]:
|
||||
@ -48,15 +65,18 @@ class ChatService(BaseService):
|
||||
else:
|
||||
await self.services.channel_message.save(channel_message)
|
||||
else:
|
||||
logger.debug(f"send: creating new message in channel={channel_uid}")
|
||||
channel_message = await self.services.channel_message.create(
|
||||
channel_uid, user_uid, message, is_final
|
||||
)
|
||||
channel_message_uid = channel_message["uid"]
|
||||
logger.debug(f"send: message saved uid={channel_message_uid}")
|
||||
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
channel["last_message_on"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
|
||||
logger.debug(f"send: broadcasting message to channel={channel_uid}")
|
||||
await self.services.socket.broadcast(
|
||||
channel_uid,
|
||||
{
|
||||
@ -73,6 +93,7 @@ class ChatService(BaseService):
|
||||
"is_final": is_final,
|
||||
},
|
||||
)
|
||||
logger.info(f"send: completed message_uid={channel_message_uid}, channel={channel_uid}, is_final={is_final}")
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(channel_message_uid)
|
||||
)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.docker import ComposeFileManager
|
||||
from snek.system.service import BaseService
|
||||
|
||||
@ -116,4 +118,3 @@ class ContainerService(BaseService):
|
||||
if await super().save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create container: {model.errors}")
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import dataset
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
# services/forum.py
|
||||
from snek.system.service import BaseService
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Any, Awaitable, Callable, Dict, List
|
||||
import asyncio
|
||||
import inspect
|
||||
from snek.system.model import now
|
||||
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
||||
class BaseForumService(BaseService):
|
||||
@ -42,10 +46,12 @@ class BaseForumService(BaseService):
|
||||
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
||||
"""Invoke every listener for the given event."""
|
||||
for listener in self._listeners.get(event_name, []):
|
||||
if hasattr(listener, "__await__"): # async function or coro
|
||||
if inspect.iscoroutinefunction(listener):
|
||||
await listener(event_name, data)
|
||||
else: # plain sync function
|
||||
listener(event_name, data)
|
||||
else:
|
||||
result = listener(event_name, data)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def notify(self, event_name: str, data: Any) -> None:
|
||||
"""
|
||||
@ -159,7 +165,7 @@ class ThreadService(BaseForumService):
|
||||
|
||||
# Check if user is admin
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user.get("is_admin"):
|
||||
if not user["is_admin"]:
|
||||
return None
|
||||
|
||||
thread["is_pinned"] = not thread["is_pinned"]
|
||||
@ -227,7 +233,7 @@ class PostService(BaseForumService):
|
||||
|
||||
# Check permissions
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
||||
if post["created_by_uid"] != user_uid and not user["is_admin"]:
|
||||
return None
|
||||
|
||||
post["content"] = content
|
||||
@ -246,7 +252,7 @@ class PostService(BaseForumService):
|
||||
|
||||
# Check permissions
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
||||
if post["created_by_uid"] != user_uid and not user["is_admin"]:
|
||||
return False
|
||||
|
||||
# Don't allow deleting first post
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.system.markdown import strip_markdown
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService(BaseService):
|
||||
mapper_name = "notification"
|
||||
@ -79,6 +85,6 @@ class NotificationService(BaseService):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send push notification:", e)
|
||||
logger.warning(f"Failed to send push notification: {e}")
|
||||
|
||||
self.app.db.commit()
|
||||
|
||||
87
src/snek/service/profile_page.py
Normal file
87
src/snek/service/profile_page.py
Normal 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)
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os.path
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
@ -11,7 +13,7 @@ class RepositoryService(BaseService):
|
||||
loop = asyncio.get_event_loop()
|
||||
repository_path = (
|
||||
await self.services.user.get_repository_path(user_uid)
|
||||
).joinpath(name)
|
||||
).joinpath(name + ".git")
|
||||
try:
|
||||
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
||||
except Exception as ex:
|
||||
@ -39,7 +41,7 @@ class RepositoryService(BaseService):
|
||||
stdout, stderr = await process.communicate()
|
||||
return process.returncode == 0
|
||||
|
||||
async def create(self, user_uid, name, is_private=False):
|
||||
async def create(self, user_uid, name, is_private=False, description=None):
|
||||
if await self.exists(user_uid=user_uid, name=name):
|
||||
return False
|
||||
|
||||
@ -50,4 +52,14 @@ class RepositoryService(BaseService):
|
||||
model["user_uid"] = user_uid
|
||||
model["name"] = name
|
||||
model["is_private"] = is_private
|
||||
model["description"] = description or ""
|
||||
return await self.save(model)
|
||||
|
||||
async def list_by_user(self, user_uid):
|
||||
repositories = []
|
||||
async for repo in self.find(user_uid=user_uid):
|
||||
repositories.append(repo)
|
||||
return repositories
|
||||
|
||||
async def get_by_name(self, user_uid, name):
|
||||
return await self.get(user_uid=user_uid, name=name)
|
||||
|
||||
@ -1,12 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.model import now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from snek.system.model import now
|
||||
|
||||
|
||||
def safe_get(obj, key, default=None):
|
||||
if obj is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
|
||||
val = obj[key]
|
||||
return val if val is not None else default
|
||||
return getattr(obj, key, default)
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_str(obj):
|
||||
if obj is None:
|
||||
return ""
|
||||
try:
|
||||
return str(obj)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class SocketService(BaseService):
|
||||
@ -16,23 +40,41 @@ class SocketService(BaseService):
|
||||
self.ws = ws
|
||||
self.is_connected = True
|
||||
self.user = user
|
||||
self.user_uid = safe_get(user, "uid") if user else None
|
||||
self.user_color = safe_get(user, "color") if user else None
|
||||
self.subscribed_channels = set()
|
||||
|
||||
async def send_json(self, data):
|
||||
if data is None:
|
||||
return False
|
||||
if not self.is_connected:
|
||||
return False
|
||||
if not self.ws:
|
||||
self.is_connected = False
|
||||
return False
|
||||
try:
|
||||
await self.ws.send_json(data)
|
||||
except Exception:
|
||||
return True
|
||||
except ConnectionResetError:
|
||||
self.is_connected = False
|
||||
return self.is_connected
|
||||
logger.debug("Connection reset during send_json")
|
||||
except Exception as ex:
|
||||
self.is_connected = False
|
||||
logger.debug(f"send_json failed: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
if not self.is_connected:
|
||||
return True
|
||||
|
||||
await self.ws.close()
|
||||
self.is_connected = False
|
||||
|
||||
self.subscribed_channels.clear()
|
||||
try:
|
||||
if self.ws and not self.ws.closed:
|
||||
await asyncio.wait_for(self.ws.close(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Socket close timed out")
|
||||
except Exception as ex:
|
||||
logger.debug(f"Socket close failed: {safe_str(ex)}")
|
||||
return True
|
||||
|
||||
def __init__(self, app):
|
||||
@ -41,74 +83,292 @@ class SocketService(BaseService):
|
||||
self.users = {}
|
||||
self.subscriptions = {}
|
||||
self.last_update = str(datetime.now())
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
logger.debug("Entering the main loop.")
|
||||
while True:
|
||||
logger.info("Updating user availability...")
|
||||
logger.debug("Initializing users_updated list.")
|
||||
users_updated = []
|
||||
logger.debug("Iterating over sockets.")
|
||||
for s in self.sockets:
|
||||
logger.debug(f"Checking connection status for socket: {s}.")
|
||||
if not s.is_connected:
|
||||
logger.debug("Socket is not connected, continuing to next socket.")
|
||||
continue
|
||||
logger.debug(f"Checking if user {s.user} is already updated.")
|
||||
if s.user not in users_updated:
|
||||
logger.debug(f"Updating last_ping for user: {s.user}.")
|
||||
s.user["last_ping"] = now()
|
||||
logger.debug(f"Saving user {s.user} to the database.")
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.debug(f"Adding user {s.user} to users_updated list.")
|
||||
users_updated.append(s.user)
|
||||
logger.info(
|
||||
f"Updated user availability for {len(users_updated)} online users."
|
||||
)
|
||||
logger.debug("Sleeping for 60 seconds before the next update.")
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
sockets_copy = list(self.sockets)
|
||||
users_to_update = []
|
||||
seen_user_uids = set()
|
||||
for s in sockets_copy:
|
||||
try:
|
||||
if not s or not s.is_connected:
|
||||
continue
|
||||
if not s.user or not s.user_uid:
|
||||
continue
|
||||
if s.user_uid in seen_user_uids:
|
||||
continue
|
||||
seen_user_uids.add(s.user_uid)
|
||||
users_to_update.append(s.user_uid)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to check user availability: {safe_str(ex)}")
|
||||
for user_uid in users_to_update:
|
||||
try:
|
||||
if self.app and hasattr(self.app, "services") and self.app.services:
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
if user:
|
||||
user["last_ping"] = now()
|
||||
await self.app.services.user.save(user)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to update user availability: {safe_str(ex)}")
|
||||
logger.info(f"Updated user availability for {len(users_to_update)} online users.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"User availability service error: {safe_str(ex)}")
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("User availability service cancelled")
|
||||
break
|
||||
|
||||
async def add(self, ws, user_uid):
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.sockets.add(s)
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.info(f"Added socket for user {s.user['username']}")
|
||||
if not self.users.get(user_uid):
|
||||
self.users[user_uid] = set()
|
||||
self.users[user_uid].add(s)
|
||||
if not ws:
|
||||
return None
|
||||
if not user_uid:
|
||||
return None
|
||||
try:
|
||||
if not self.app or not hasattr(self.app, "services") or not self.app.services:
|
||||
logger.warning("Services not available for socket add")
|
||||
return None
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
if not user:
|
||||
logger.warning(f"User not found for socket add: {user_uid}")
|
||||
return None
|
||||
s = self.Socket(ws, user)
|
||||
username = safe_get(user, "username", "unknown")
|
||||
nick = safe_get(user, "nick") or username
|
||||
color = s.user_color
|
||||
async with self._lock:
|
||||
self.sockets.add(s)
|
||||
is_first_connection = False
|
||||
if user_uid not in self.users:
|
||||
self.users[user_uid] = set()
|
||||
is_first_connection = True
|
||||
elif len(self.users[user_uid]) == 0:
|
||||
is_first_connection = True
|
||||
self.users[user_uid].add(s)
|
||||
try:
|
||||
fresh_user = await self.app.services.user.get(uid=user_uid)
|
||||
if fresh_user:
|
||||
fresh_user["last_ping"] = now()
|
||||
await self.app.services.user.save(fresh_user)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
|
||||
logger.info(f"Added socket for user {username}")
|
||||
if is_first_connection:
|
||||
await self._broadcast_presence("arrived", user_uid, nick, color)
|
||||
return s
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to add socket: {safe_str(ex)}")
|
||||
return None
|
||||
|
||||
async def subscribe(self, ws, channel_uid, user_uid):
|
||||
if channel_uid not in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.subscriptions[channel_uid].add(s)
|
||||
if not ws or not channel_uid or not user_uid:
|
||||
return False
|
||||
try:
|
||||
async with self._lock:
|
||||
existing_socket = None
|
||||
user_sockets = self.users.get(user_uid, set())
|
||||
for sock in user_sockets:
|
||||
if sock and sock.ws == ws:
|
||||
existing_socket = sock
|
||||
break
|
||||
if not existing_socket:
|
||||
return False
|
||||
existing_socket.subscribed_channels.add(channel_uid)
|
||||
if channel_uid not in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
self.subscriptions[channel_uid].add(user_uid)
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def unsubscribe(self, ws, channel_uid, user_uid):
|
||||
if not ws or not channel_uid or not user_uid:
|
||||
return False
|
||||
try:
|
||||
async with self._lock:
|
||||
if channel_uid in self.subscriptions:
|
||||
self.subscriptions[channel_uid].discard(user_uid)
|
||||
if len(self.subscriptions[channel_uid]) == 0:
|
||||
del self.subscriptions[channel_uid]
|
||||
for s in self.sockets:
|
||||
if s and s.ws == ws:
|
||||
s.subscribed_channels.discard(channel_uid)
|
||||
break
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to unsubscribe: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
if not user_uid or message is None:
|
||||
return 0
|
||||
count = 0
|
||||
for s in list(self.users.get(user_uid, [])):
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
try:
|
||||
user_sockets = list(self.users.get(user_uid, []))
|
||||
for s in user_sockets:
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send to user socket: {safe_str(ex)}")
|
||||
except Exception as ex:
|
||||
logger.warning(f"send_to_user failed: {safe_str(ex)}")
|
||||
return count
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
await self._broadcast(channel_uid, message)
|
||||
if not channel_uid or message is None:
|
||||
logger.debug(f"broadcast: invalid params channel_uid={channel_uid}, message={message is not None}")
|
||||
return False
|
||||
logger.debug(f"broadcast: starting for channel={channel_uid}")
|
||||
return await self._broadcast(channel_uid, message)
|
||||
|
||||
async def _broadcast(self, channel_uid, message):
|
||||
if not channel_uid or message is None:
|
||||
return False
|
||||
sent = 0
|
||||
user_uids_to_send = set()
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(
|
||||
channel_uid
|
||||
):
|
||||
sent += await self.send_to_user(user_uid, message)
|
||||
if self.services:
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(channel_uid):
|
||||
if user_uid:
|
||||
user_uids_to_send.add(user_uid)
|
||||
logger.debug(f"_broadcast: found {len(user_uids_to_send)} users from db for channel={channel_uid}")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
|
||||
if not user_uids_to_send:
|
||||
if channel_uid in self.subscriptions:
|
||||
user_uids_to_send = set(self.subscriptions[channel_uid])
|
||||
logger.debug(f"_broadcast: using {len(user_uids_to_send)} users from subscriptions for channel={channel_uid}")
|
||||
send_tasks = []
|
||||
for user_uid in user_uids_to_send:
|
||||
send_tasks.append(self._send_to_user_safe(user_uid, message))
|
||||
if send_tasks:
|
||||
results = await asyncio.gather(*send_tasks, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, int):
|
||||
sent += result
|
||||
logger.info(f"_broadcast: completed channel={channel_uid}, total_users={len(user_uids_to_send)}, sent={sent}")
|
||||
return True
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
logger.info(f"Broadcasted a message to {sent} users.")
|
||||
return True
|
||||
logger.warning(f"Broadcast failed: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def _send_to_user_safe(self, user_uid, message):
|
||||
try:
|
||||
return await self.send_to_user(user_uid, message)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send to user {user_uid}: {safe_str(ex)}")
|
||||
return 0
|
||||
|
||||
async def delete(self, ws):
|
||||
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
||||
await s.close()
|
||||
logger.info(f"Removed socket for user {s.user['username']}")
|
||||
self.sockets.remove(s)
|
||||
if not ws:
|
||||
return
|
||||
async with self._lock:
|
||||
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
|
||||
for s in sockets_to_remove:
|
||||
self.sockets.discard(s)
|
||||
departures_to_broadcast = []
|
||||
channels_to_cleanup = set()
|
||||
for s in sockets_to_remove:
|
||||
user_uid = s.user_uid
|
||||
if not user_uid:
|
||||
continue
|
||||
is_last_connection = False
|
||||
if user_uid in self.users:
|
||||
self.users[user_uid].discard(s)
|
||||
if len(self.users[user_uid]) == 0:
|
||||
del self.users[user_uid]
|
||||
is_last_connection = True
|
||||
if is_last_connection:
|
||||
user_nick = None
|
||||
try:
|
||||
if s.user:
|
||||
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
|
||||
except Exception:
|
||||
pass
|
||||
if user_nick:
|
||||
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
|
||||
for channel_uid in list(s.subscribed_channels):
|
||||
channels_to_cleanup.add((channel_uid, user_uid))
|
||||
for channel_uid, user_uid in channels_to_cleanup:
|
||||
try:
|
||||
if channel_uid in self.subscriptions:
|
||||
self.subscriptions[channel_uid].discard(user_uid)
|
||||
if len(self.subscriptions[channel_uid]) == 0:
|
||||
del self.subscriptions[channel_uid]
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to cleanup channel subscription: {safe_str(ex)}")
|
||||
for s in sockets_to_remove:
|
||||
try:
|
||||
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
|
||||
logger.info(f"Removed socket for user {username}")
|
||||
await s.close()
|
||||
except Exception as ex:
|
||||
logger.warning(f"Socket close failed: {safe_str(ex)}")
|
||||
for user_uid, user_nick, user_color in departures_to_broadcast:
|
||||
try:
|
||||
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to broadcast departure: {safe_str(ex)}")
|
||||
|
||||
async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color):
|
||||
if not user_uid or not user_nick:
|
||||
return
|
||||
if not event_type or event_type not in ("arrived", "departed"):
|
||||
return
|
||||
try:
|
||||
message = {
|
||||
"event": "user_presence",
|
||||
"data": {
|
||||
"type": event_type,
|
||||
"user_uid": user_uid,
|
||||
"user_nick": user_nick,
|
||||
"user_color": user_color,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
sockets_copy = list(self.sockets)
|
||||
send_tasks = []
|
||||
for s in sockets_copy:
|
||||
if not s or not s.is_connected:
|
||||
continue
|
||||
if s.user_uid == user_uid:
|
||||
continue
|
||||
send_tasks.append(s.send_json(message))
|
||||
if send_tasks:
|
||||
results = await asyncio.gather(*send_tasks, return_exceptions=True)
|
||||
sent_count = sum(1 for r in results if r is True)
|
||||
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Broadcast presence failed: {safe_str(ex)}")
|
||||
|
||||
async def get_connected_users(self):
|
||||
try:
|
||||
return list(self.users.keys())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_user_socket_count(self, user_uid):
|
||||
if not user_uid:
|
||||
return 0
|
||||
try:
|
||||
return len(self.users.get(user_uid, []))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def is_user_online(self, user_uid):
|
||||
if not user_uid:
|
||||
return False
|
||||
try:
|
||||
user_sockets = self.users.get(user_uid, set())
|
||||
return any(s.is_connected for s in user_sockets if s)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
from snek.system.service import BaseService
|
||||
import sqlite3
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sqlite3
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class StatisticsService(BaseService):
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pathlib
|
||||
|
||||
from snek.system import security
|
||||
@ -78,7 +80,7 @@ class UserService(BaseService):
|
||||
if not folder.exists():
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
except:
|
||||
except OSError:
|
||||
pass
|
||||
return folder
|
||||
|
||||
@ -87,7 +89,7 @@ class UserService(BaseService):
|
||||
if not folder.exists():
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
except:
|
||||
except OSError:
|
||||
pass
|
||||
return folder
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from snek.system.service import BaseService
|
||||
@ -15,6 +17,7 @@ class UserPropertyService(BaseService):
|
||||
},
|
||||
["user_uid", "name"],
|
||||
)
|
||||
self.mapper.db.commit()
|
||||
|
||||
async def get(self, user_uid, name):
|
||||
try:
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import random
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
235
src/snek/sgit.py
235
src/snek/sgit.py
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
@ -16,61 +18,72 @@ logger = logging.getLogger("git_server")
|
||||
|
||||
class GitApplication(web.Application):
|
||||
def __init__(self, parent=None):
|
||||
# import git
|
||||
# globals()['git'] = git
|
||||
import git
|
||||
globals()['git'] = git
|
||||
self.parent = parent
|
||||
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
|
||||
self.add_routes(
|
||||
[
|
||||
web.post("/create/{repo_name}", self.create_repository),
|
||||
web.delete("/delete/{repo_name}", self.delete_repository),
|
||||
web.get("/clone/{repo_name}", self.clone_repository),
|
||||
web.post("/push/{repo_name}", self.push_repository),
|
||||
web.post("/pull/{repo_name}", self.pull_repository),
|
||||
web.get("/status/{repo_name}", self.status_repository),
|
||||
# web.get('/list', self.list_repositories),
|
||||
web.get("/branches/{repo_name}", self.list_branches),
|
||||
web.post("/branches/{repo_name}", self.create_branch),
|
||||
web.get("/log/{repo_name}", self.commit_log),
|
||||
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
|
||||
web.get("/{path:.+}/info/refs", self.git_smart_http),
|
||||
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
|
||||
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
|
||||
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}/create", self.create_repository),
|
||||
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
|
||||
web.get("/{username}/{repo_name}/clone", self.clone_repository),
|
||||
web.post("/{username}/{repo_name}/push", self.push_repository),
|
||||
web.post("/{username}/{repo_name}/pull", self.pull_repository),
|
||||
web.get("/{username}/{repo_name}/status", self.status_repository),
|
||||
web.get("/{username}/{repo_name}/branches", self.list_branches),
|
||||
web.post("/{username}/{repo_name}/branches", self.create_branch),
|
||||
web.get("/{username}/{repo_name}/log", self.commit_log),
|
||||
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
|
||||
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
|
||||
]
|
||||
)
|
||||
|
||||
async def check_basic_auth(self, request):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
return None, None
|
||||
return None, None, None
|
||||
encoded_creds = auth_header.split("Basic ")[1]
|
||||
decoded_creds = base64.b64decode(encoded_creds).decode()
|
||||
username, password = decoded_creds.split(":", 1)
|
||||
request["user"] = await self.parent.services.user.authenticate(
|
||||
request["auth_user"] = await self.parent.services.user.authenticate(
|
||||
username=username, password=password
|
||||
)
|
||||
if not request["user"]:
|
||||
return None, None
|
||||
if not request["auth_user"]:
|
||||
return None, None, None
|
||||
|
||||
path_username = request.match_info.get("username")
|
||||
if not path_username:
|
||||
return None, None, None
|
||||
|
||||
if path_username.count("-") == 4:
|
||||
target_user = await self.parent.services.user.get(uid=path_username)
|
||||
else:
|
||||
target_user = await self.parent.services.user.get(username=path_username)
|
||||
|
||||
if not target_user:
|
||||
return None, None, None
|
||||
|
||||
request["target_user"] = target_user
|
||||
request["repository_path"] = (
|
||||
await self.parent.services.user.get_repository_path(request["user"]["uid"])
|
||||
await self.parent.services.user.get_repository_path(target_user["uid"])
|
||||
)
|
||||
|
||||
return request["user"]["username"], request["repository_path"]
|
||||
return request["auth_user"]["username"], target_user, request["repository_path"]
|
||||
|
||||
@staticmethod
|
||||
def require_auth(handler):
|
||||
async def wrapped(self, request, *args, **kwargs):
|
||||
username, repository_path = await self.check_basic_auth(request)
|
||||
if not username or not repository_path:
|
||||
username, target_user, repository_path = await self.check_basic_auth(request)
|
||||
if not username or not target_user or not repository_path:
|
||||
return web.Response(
|
||||
status=401,
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
text="Authentication required",
|
||||
)
|
||||
request["username"] = username
|
||||
request["target_user"] = target_user
|
||||
request["repository_path"] = repository_path
|
||||
return await handler(self, request, *args, **kwargs)
|
||||
|
||||
@ -87,9 +100,17 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def create_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only create repositories in your own namespace",
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not repo_name or "/" in repo_name or ".." in repo_name:
|
||||
return web.Response(text="Invalid repository name", status=400)
|
||||
repo_dir = self.repo_path(repository_path, repo_name)
|
||||
@ -97,7 +118,7 @@ class GitApplication(web.Application):
|
||||
return web.Response(text="Repository already exists", status=400)
|
||||
try:
|
||||
git.Repo.init(repo_dir, bare=True)
|
||||
logger.info(f"Created repository: {repo_name} for user {username}")
|
||||
logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Created repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
||||
@ -105,16 +126,22 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def delete_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only delete your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
#'''
|
||||
try:
|
||||
shutil.rmtree(self.repo_path(repository_path, repo_name))
|
||||
logger.info(f"Deleted repository: {repo_name} for user {username}")
|
||||
logger.info(f"Deleted repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Deleted repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
||||
@ -122,9 +149,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def clone_repository(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -139,9 +177,16 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def push_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only push to your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -175,14 +220,21 @@ class GitApplication(web.Application):
|
||||
temp_repo.index.commit(commit_message)
|
||||
origin = temp_repo.remote("origin")
|
||||
origin.push(refspec=f"{branch}:{branch}")
|
||||
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
||||
logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||
|
||||
@require_auth
|
||||
async def pull_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only pull to your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -210,7 +262,7 @@ class GitApplication(web.Application):
|
||||
origin = local_repo.remote("origin")
|
||||
origin.push()
|
||||
logger.info(
|
||||
f"Pulled to repository {repo_name} from {remote_url} for user {username}"
|
||||
f"Pulled to repository {repo_name} from {remote_url} for user {auth_user['username']}"
|
||||
)
|
||||
return web.Response(
|
||||
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
|
||||
@ -221,9 +273,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def status_repository(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -291,9 +354,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def list_branches(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -306,9 +380,17 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def create_branch(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only create branches in your own repositories",
|
||||
status=403,
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -328,7 +410,7 @@ class GitApplication(web.Application):
|
||||
temp_repo.git.branch(branch_name, start_point)
|
||||
temp_repo.git.push("origin", branch_name)
|
||||
logger.info(
|
||||
f"Created branch {branch_name} in repository {repo_name} for user {username}"
|
||||
f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
|
||||
)
|
||||
return web.Response(text=f"Created branch {branch_name}")
|
||||
except Exception as e:
|
||||
@ -339,9 +421,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def commit_log(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -383,11 +476,22 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def file_content(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
file_path = request.match_info.get("file_path", "")
|
||||
branch = request.query.get("branch", "main")
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -433,25 +537,42 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def git_smart_http(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repository_path = request["repository_path"]
|
||||
repo_name = request.match_info.get("repo_name")
|
||||
path_username = request.match_info.get("username")
|
||||
path = request.path
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
is_owner = auth_user["uid"] == target_user["uid"]
|
||||
is_write_operation = "/git-receive-pack" in path
|
||||
|
||||
if is_write_operation and not is_owner:
|
||||
logger.warning(
|
||||
f"Push denied: {auth_user['username']} attempted to push to {path_username}/{repo_name}"
|
||||
)
|
||||
return web.Response(
|
||||
text="Push denied: only repository owner can push", status=403
|
||||
)
|
||||
|
||||
if not is_owner and repo["is_private"]:
|
||||
logger.warning(
|
||||
f"Access denied: {auth_user['username']} attempted to access private repo {path_username}/{repo_name}"
|
||||
)
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
async def get_repository_path():
|
||||
req_path = path.lstrip("/")
|
||||
if req_path.endswith("/info/refs"):
|
||||
repo_name = req_path[: -len("/info/refs")]
|
||||
elif req_path.endswith("/git-upload-pack"):
|
||||
repo_name = req_path[: -len("/git-upload-pack")]
|
||||
elif req_path.endswith("/git-receive-pack"):
|
||||
repo_name = req_path[: -len("/git-receive-pack")]
|
||||
else:
|
||||
repo_name = req_path
|
||||
if repo_name.endswith(".git"):
|
||||
repo_name = repo_name[:-4]
|
||||
repo_name = repo_name[4:]
|
||||
repo_dir = repository_path.joinpath(repo_name + ".git")
|
||||
logger.info(f"Resolved repo path: {repo_dir}")
|
||||
logger.info(
|
||||
f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
|
||||
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
|
||||
)
|
||||
return repo_dir
|
||||
|
||||
async def handle_info_refs(service):
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.app import Application
|
||||
from IPython import start_ipython
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import aiohttp
|
||||
|
||||
ENABLED = False
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This project implements a client-server communication system using WebSockets and REST APIs.
|
||||
// It features a chat system, a notification sound system, and interaction with server endpoints.
|
||||
|
||||
// No additional imports were used beyond standard JavaScript objects and constructors.
|
||||
|
||||
// MIT License
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Schedule } from "./schedule.js";
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
import { Socket } from "./socket.js";
|
||||
import { Njet } from "./njet.js";
|
||||
import { PresenceNotification } from "./presence-notification.js";
|
||||
export class RESTClient {
|
||||
debug = false;
|
||||
|
||||
@ -160,9 +154,10 @@ export class App extends EventHandler {
|
||||
typeLock = null;
|
||||
typeListener = null;
|
||||
typeEventChannelUid = null;
|
||||
_debug = false
|
||||
_debug = false;
|
||||
presenceNotification = null;
|
||||
async set_typing(channel_uid) {
|
||||
this.typeEventChannel_uid = channel_uid;
|
||||
this.typeEventChannelUid = channel_uid;
|
||||
}
|
||||
debug() {
|
||||
this._debug = !this._debug;
|
||||
@ -174,17 +169,8 @@ export class App extends EventHandler {
|
||||
await this.rpc.ping(...args);
|
||||
this.is_pinging = false;
|
||||
}
|
||||
ntsh(times,message) {
|
||||
if(!message)
|
||||
message = "Nothing to see here!"
|
||||
if(!times)
|
||||
times=100
|
||||
for(let x = 0; x < times; x++){
|
||||
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
|
||||
}
|
||||
}
|
||||
async forcePing(...arg) {
|
||||
await this.rpc.ping(...args);
|
||||
await this.rpc.ping(...arg);
|
||||
}
|
||||
starField = null
|
||||
constructor() {
|
||||
@ -192,6 +178,7 @@ export class App extends EventHandler {
|
||||
this.ws = new Socket();
|
||||
this.rpc = this.ws.client;
|
||||
this.audio = new NotificationAudio(500);
|
||||
this.presenceNotification = new PresenceNotification(this.ws);
|
||||
this.is_pinging = false;
|
||||
this.ping_interval = setInterval(() => {
|
||||
this.ping("active");
|
||||
@ -202,7 +189,7 @@ export class App extends EventHandler {
|
||||
this.rpc.set_typing(this.typeEventChannelUid);
|
||||
this.typeEventChannelUid = null;
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const me = this;
|
||||
this.ws.addEventListener("connected", (data) => {
|
||||
|
||||
@ -66,11 +66,13 @@ header .logo {
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: #aaa;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
margin-left: 15px;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
@ -82,6 +84,7 @@ header nav a {
|
||||
|
||||
header nav a:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
a {
|
||||
@ -407,34 +410,69 @@ a {
|
||||
width: 250px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 10px;
|
||||
padding-top: 20px;
|
||||
overflow-y: auto;
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
color: #f05a28;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sidebar h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-add-btn {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-add-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
color: #ccc;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
color: #fff;
|
||||
color: #e6e6e6;
|
||||
background-color: #1a1a1a;
|
||||
border-left-color: #444;
|
||||
}
|
||||
|
||||
.sidebar ul li a.active {
|
||||
color: #f05a28;
|
||||
border-left-color: #f05a28;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
@ -459,31 +497,40 @@ a {
|
||||
header {
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
header > * {
|
||||
display: inline-block !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
header .logo {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
header .logo h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
header nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
header nav-menu,
|
||||
header channel-menu {
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
/*
|
||||
@ -549,12 +596,22 @@ dialog .dialog-actions {
|
||||
}
|
||||
|
||||
dialog .dialog-button {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
dialog .dialog-button:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@ -580,38 +637,40 @@ dialog .dialog-button {
|
||||
|
||||
dialog .dialog-button.primary {
|
||||
background-color: #f05a28;
|
||||
color: white;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary:hover {
|
||||
background-color: #f05a28;
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary {
|
||||
background-color: #f0a328;
|
||||
color: #eee;
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary:hover {
|
||||
background-color: #f0b84c;
|
||||
background-color: #3a3a3a;
|
||||
border-color: #555;
|
||||
}
|
||||
dialog .dialog-button.primary:disabled,
|
||||
dialog .dialog-button.primary[aria-disabled="true"] {
|
||||
/* slightly darker + lower saturation of the live colour */
|
||||
background-color: #70321e; /* muted burnt orange */
|
||||
color: #bfbfbf; /* light grey text */
|
||||
|
||||
opacity: .55; /* unified fade */
|
||||
background-color: #0a0a0a;
|
||||
border-color: #222;
|
||||
color: #555;
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- SECONDARY (yellow) ---------- */
|
||||
dialog .dialog-button.secondary:disabled,
|
||||
dialog .dialog-button.secondary[aria-disabled="true"] {
|
||||
background-color: #6c5619; /* muted mustard */
|
||||
color: #bfbfbf;
|
||||
|
||||
background-color: #0a0a0a;
|
||||
border-color: #222;
|
||||
color: #555;
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
@ -621,6 +680,119 @@ dialog .dialog-button:disabled:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
dialog .dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
dialog .dialog-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
dialog .dialog-input:focus {
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
|
||||
dialog .dialog-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
dialog .dialog-input.error {
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
dialog .dialog-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e6e6e6;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
dialog .dialog-checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #f05a28;
|
||||
}
|
||||
|
||||
dialog .dialog-suggestions {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
dialog .dialog-suggestion-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
dialog .dialog-suggestion-item:hover {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
dialog .dialog-box-wide {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
dialog .dialog-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
dialog .dialog-label-danger {
|
||||
color: #8b0000;
|
||||
}
|
||||
|
||||
dialog .dialog-divider {
|
||||
height: 1px;
|
||||
background-color: #333;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
dialog .dialog-danger-zone {
|
||||
padding: 12px;
|
||||
border: 1px solid #8b0000;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(139, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
dialog .dialog-button.danger {
|
||||
background-color: #8b0000;
|
||||
border-color: #8b0000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
dialog .dialog-button.danger:hover {
|
||||
background-color: #a00000;
|
||||
border-color: #a00000;
|
||||
}
|
||||
|
||||
dialog .dialog-button.danger:disabled {
|
||||
background-color: #0a0a0a;
|
||||
border-color: #222;
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.embed-url-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
93
src/snek/static/buttons.css
Normal file
93
src/snek/static/buttons.css
Normal 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%;
|
||||
}
|
||||
123
src/snek/static/channel-menu.css
Normal file
123
src/snek/static/channel-menu.css
Normal 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;
|
||||
}
|
||||
171
src/snek/static/channel-menu.js
Normal file
171
src/snek/static/channel-menu.js
Normal 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);
|
||||
@ -1,6 +1,12 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { app } from "./app.js";
|
||||
import { NjetComponent,eventBus } from "./njet.js";
|
||||
import { NjetComponent, eventBus } from "./njet.js";
|
||||
import { FileUploadGrid } from "./file-upload-grid.js";
|
||||
import { loggerFactory } from "./logger.js";
|
||||
import "./toolbar-menu.js";
|
||||
|
||||
const log = loggerFactory.getLogger("ChatInput");
|
||||
|
||||
class ChatInputComponent extends NjetComponent {
|
||||
autoCompletions = {
|
||||
@ -368,22 +374,16 @@ textToLeetAdvanced(text) {
|
||||
this.fileUploadGrid = new FileUploadGrid();
|
||||
this.fileUploadGrid.setAttribute("channel", this.channelUid);
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
this.appendChild(this.fileUploadGrid);
|
||||
this.parentElement.insertBefore(this.fileUploadGrid, this);
|
||||
|
||||
this.textarea.setAttribute("placeholder", "Type a message...");
|
||||
this.textarea.setAttribute("rows", "2");
|
||||
|
||||
this.appendChild(this.textarea);
|
||||
this.ttsButton = document.createElement("stt-button");
|
||||
|
||||
this.snekSpeaker = document.createElement("snek-speaker");
|
||||
this.appendChild(this.snekSpeaker);
|
||||
|
||||
this.ttsButton.addEventListener("click", (e) => {
|
||||
this.snekSpeaker.enable()
|
||||
});
|
||||
this.sttButton = document.createElement("stt-button");
|
||||
this.ttsButton = document.createElement("tts-button");
|
||||
|
||||
this.appendChild(this.ttsButton);
|
||||
this.uploadButton = document.createElement("upload-button");
|
||||
this.uploadButton.setAttribute("channel", this.channelUid);
|
||||
this.uploadButton.addEventListener("upload", (e) => {
|
||||
@ -398,10 +398,13 @@ textToLeetAdvanced(text) {
|
||||
});
|
||||
this.subscribe("file-uploading", (e) => {
|
||||
this.fileUploadGrid.style.display = "block";
|
||||
this.uploadButton.style.display = "none";
|
||||
this.textarea.style.display = "none";
|
||||
})
|
||||
this.appendChild(this.uploadButton);
|
||||
});
|
||||
|
||||
this.toolbarMenu = document.createElement("toolbar-menu");
|
||||
this.toolbarMenu.addButton(this.sttButton, 'stt');
|
||||
this.toolbarMenu.addButton(this.ttsButton, 'tts');
|
||||
this.toolbarMenu.addButton(this.uploadButton, 'upload');
|
||||
this.appendChild(this.toolbarMenu);
|
||||
|
||||
this.textarea.addEventListener("blur", () => {
|
||||
this.updateFromInput(this.value, true).then(
|
||||
@ -410,8 +413,6 @@ textToLeetAdvanced(text) {
|
||||
});
|
||||
|
||||
this.subscribe("file-uploads-done", (data)=>{
|
||||
this.textarea.style.display = "block";
|
||||
this.uploadButton.style.display = "block";
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
let msg =data.reduce((message, file) => {
|
||||
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
|
||||
@ -504,7 +505,10 @@ textToLeetAdvanced(text) {
|
||||
flagTyping() {
|
||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
|
||||
this.lastUpdateEvent = new Date();
|
||||
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
|
||||
log.debug("Flagging typing indicator", { channelUid: this.channelUid });
|
||||
app.rpc.set_typing(this.channelUid, this.user?.color).catch((e) => {
|
||||
log.warn("set_typing failed", { error: e, channelUid: this.channelUid });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -516,7 +520,12 @@ textToLeetAdvanced(text) {
|
||||
}else if(this._leetSpeakAdvanced){
|
||||
value = this.textToLeetAdvanced(value);
|
||||
}
|
||||
app.rpc.sendMessage(this.channelUid, value , true);
|
||||
log.info("Finalizing message", { channelUid: this.channelUid, messageLength: value.length, messageUid });
|
||||
app.rpc.sendMessage(this.channelUid, value , true).then((result) => {
|
||||
log.debug("Message finalized successfully", { channelUid: this.channelUid, result });
|
||||
}).catch((e) => {
|
||||
log.error("Failed to finalize message", { channelUid: this.channelUid, error: e });
|
||||
});
|
||||
this.value = "";
|
||||
this.messageUid = null;
|
||||
this.queuedMessage = null;
|
||||
@ -526,6 +535,7 @@ textToLeetAdvanced(text) {
|
||||
|
||||
|
||||
updateFromInput(value, isFinal = false) {
|
||||
log.debug("updateFromInput called", { valueLength: value?.length, isFinal, liveType: this.liveType, channelUid: this.channelUid });
|
||||
|
||||
this.value = value;
|
||||
|
||||
@ -533,13 +543,22 @@ textToLeetAdvanced(text) {
|
||||
|
||||
if (this.liveType && value[0] !== "/") {
|
||||
const messageText = this.replaceMentionsWithAuthors(value);
|
||||
log.debug("Sending live type message", { channelUid: this.channelUid, messageLength: messageText?.length, isFinal: !this.liveType || isFinal });
|
||||
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
|
||||
return this.messageUid;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(channelUid, value, is_final) {
|
||||
return await app.rpc.sendMessage(channelUid, value, is_final);
|
||||
log.info("sendMessage called", { channelUid, valueLength: value?.length, is_final });
|
||||
try {
|
||||
const result = await app.rpc.sendMessage(channelUid, value, is_final);
|
||||
log.debug("sendMessage completed", { channelUid, result, is_final });
|
||||
return result;
|
||||
} catch (e) {
|
||||
log.error("sendMessage failed", { channelUid, error: e, is_final });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines a custom HTML element called ChatWindowElement that provides a chat interface within a shadow DOM, handling connection callbacks, displaying messages, and user interactions.
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { app } from "./app.js";
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
|
||||
@ -30,7 +32,7 @@ export class Container extends EventHandler{
|
||||
}
|
||||
refresh(){
|
||||
//this._fitAddon.fit();
|
||||
this.ws.send("\x0C");
|
||||
this.ws.send("");
|
||||
}
|
||||
toggle(){
|
||||
this._container.classList.toggle("hidden")
|
||||
@ -110,4 +112,3 @@ export class Container extends EventHandler{
|
||||
window.getContainer = function(){
|
||||
return new Container(app.channelUid)
|
||||
}*/
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { NjetComponent } from "/njet.js";
|
||||
class WebTerminal extends NjetComponent {
|
||||
|
||||
@ -247,4 +249,3 @@ window.showTerm = function (options) {
|
||||
|
||||
customElements.define("web-terminal", WebTerminal);
|
||||
export { WebTerminal };
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { NjetComponent } from "/njet.js"
|
||||
|
||||
class NjetEditor extends NjetComponent {
|
||||
@ -279,7 +281,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
|
||||
getCurrentLineInfo() {
|
||||
const text = this.editor.innerText;
|
||||
const caretPos = this.getCaretOffset();
|
||||
const lines = text.split('\n');
|
||||
const lines = text.split('
|
||||
');
|
||||
|
||||
let charCount = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
@ -405,7 +408,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
|
||||
this.lastDeletedLine = lines[lineIndex];
|
||||
lines.splice(lineIndex, 1);
|
||||
if (lines.length === 0) lines.push('');
|
||||
this.editor.innerText = lines.join('\n');
|
||||
this.editor.innerText = lines.join('
|
||||
');
|
||||
this.setCaretOffset(lineStartOffset);
|
||||
break;
|
||||
|
||||
@ -414,7 +418,8 @@ Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
|
||||
const lineToPaste = this.yankedLine || this.lastDeletedLine;
|
||||
if (lineToPaste) {
|
||||
lines.splice(lineIndex + 1, 0, lineToPaste);
|
||||
this.editor.innerText = lines.join('\n');
|
||||
this.editor.innerText = lines.join('
|
||||
');
|
||||
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -1,33 +1,178 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class EventHandler {
|
||||
constructor() {
|
||||
this.subscribers = {};
|
||||
this._maxListeners = 100;
|
||||
this._warnOnMaxListeners = true;
|
||||
}
|
||||
|
||||
addEventListener(type, handler, { once = false } = {}) {
|
||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
handler = (...args) => {
|
||||
originalHandler(...args);
|
||||
this.removeEventListener(type, handler);
|
||||
};
|
||||
addEventListener(type, handler, options = {}) {
|
||||
if (!type || typeof type !== "string") {
|
||||
console.warn("EventHandler: Invalid event type");
|
||||
return false;
|
||||
}
|
||||
if (!handler || typeof handler !== "function") {
|
||||
console.warn("EventHandler: Invalid handler");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const once = options && options.once === true;
|
||||
if (!this.subscribers[type]) {
|
||||
this.subscribers[type] = [];
|
||||
}
|
||||
if (this._warnOnMaxListeners && this.subscribers[type].length >= this._maxListeners) {
|
||||
console.warn(`EventHandler: Max listeners (${this._maxListeners}) reached for event "${type}"`);
|
||||
}
|
||||
let wrappedHandler = handler;
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
wrappedHandler = (...args) => {
|
||||
try {
|
||||
originalHandler(...args);
|
||||
} catch (e) {
|
||||
console.error(`EventHandler: Error in once handler for "${type}":`, e);
|
||||
} finally {
|
||||
this.removeEventListener(type, wrappedHandler);
|
||||
}
|
||||
};
|
||||
wrappedHandler._original = originalHandler;
|
||||
}
|
||||
this.subscribers[type].push(wrappedHandler);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: addEventListener error:", e);
|
||||
return false;
|
||||
}
|
||||
this.subscribers[type].push(handler);
|
||||
}
|
||||
|
||||
emit(type, ...data) {
|
||||
if (this.subscribers[type])
|
||||
this.subscribers[type].forEach((handler) => handler(...data));
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const handlers = this.subscribers[type];
|
||||
if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const handlersCopy = [...handlers];
|
||||
for (const handler of handlersCopy) {
|
||||
if (typeof handler !== "function") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handler(...data);
|
||||
} catch (e) {
|
||||
console.error(`EventHandler: Error in handler for "${type}":`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: emit error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type, handler) {
|
||||
if (!this.subscribers[type]) return;
|
||||
this.subscribers[type] = this.subscribers[type].filter(
|
||||
(h) => h !== handler
|
||||
);
|
||||
|
||||
if (this.subscribers[type].length === 0) {
|
||||
delete this.subscribers[type];
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!this.subscribers[type]) {
|
||||
return false;
|
||||
}
|
||||
if (!handler) {
|
||||
delete this.subscribers[type];
|
||||
return true;
|
||||
}
|
||||
const originalLength = this.subscribers[type].length;
|
||||
this.subscribers[type] = this.subscribers[type].filter((h) => {
|
||||
if (h === handler) return false;
|
||||
if (h._original && h._original === handler) return false;
|
||||
return true;
|
||||
});
|
||||
if (this.subscribers[type].length === 0) {
|
||||
delete this.subscribers[type];
|
||||
}
|
||||
return this.subscribers[type] ? this.subscribers[type].length < originalLength : true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: removeEventListener error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeAllEventListeners(type) {
|
||||
try {
|
||||
if (type) {
|
||||
if (this.subscribers[type]) {
|
||||
delete this.subscribers[type];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.subscribers = {};
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: removeAllEventListeners error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasEventListener(type, handler) {
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!this.subscribers[type]) {
|
||||
return false;
|
||||
}
|
||||
if (!handler) {
|
||||
return this.subscribers[type].length > 0;
|
||||
}
|
||||
return this.subscribers[type].some((h) => {
|
||||
if (h === handler) return true;
|
||||
if (h._original && h._original === handler) return true;
|
||||
return false;
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getEventListenerCount(type) {
|
||||
try {
|
||||
if (type) {
|
||||
return this.subscribers[type] ? this.subscribers[type].length : 0;
|
||||
}
|
||||
let count = 0;
|
||||
for (const key in this.subscribers) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.subscribers, key)) {
|
||||
count += this.subscribers[key].length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getEventTypes() {
|
||||
try {
|
||||
return Object.keys(this.subscribers);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
once(type, handler) {
|
||||
return this.addEventListener(type, handler, { once: true });
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
return this.removeEventListener(type, handler);
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
return this.addEventListener(type, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
||||
@ -28,21 +30,30 @@ class FancyButton extends HTMLElement {
|
||||
button {
|
||||
width: var(--width);
|
||||
min-width: ${size};
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #f05a28;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
button:hover {
|
||||
color: #EFEFEF;
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border: 1px solid #efefef;
|
||||
border-color: #e04924;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
/* A <file-browser> custom element that talks to /api/files */
|
||||
import { NjetComponent } from "/njet.js";
|
||||
|
||||
|
||||
@ -1,23 +1,32 @@
|
||||
.fug-root {
|
||||
background: #181818;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin: 0 15px 8px 15px;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
/*min-height: 100vh;*/
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
.fug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 150px);
|
||||
gap: 20px;
|
||||
margin: 30px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 10px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.fug-tile {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
background: #111;
|
||||
border: 2px solid #ff6600;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 8px 8px 8px;
|
||||
padding: 12px 8px 8px 8px;
|
||||
box-shadow: 0 0 4px #333;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { NjetComponent, NjetDialog } from '/njet.js';
|
||||
|
||||
const FUG_ICONS = {
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #0a0a0a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
@ -15,61 +15,72 @@
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
generic-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: #000000;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
||||
input {
|
||||
|
||||
border: 10px solid #000000;
|
||||
}
|
||||
.generic-form-container generic-field {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.generic-form-container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generic-form-container button:hover {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.generic-form-container button[type="submit"],
|
||||
.generic-form-container button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.generic-form-container button[type="submit"]:hover,
|
||||
.generic-form-container button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
.generic-form-container a {
|
||||
@ -85,15 +96,13 @@ input {
|
||||
color: #e04924;
|
||||
}
|
||||
|
||||
|
||||
.error {
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.generic-form-container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines two custom HTML elements, `GenericField` and `GenericForm`. The `GenericField` element represents a form field with validation and styling functionalities, and the `GenericForm` fetches and manages form data, handling field validation and submission.
|
||||
@ -76,47 +78,66 @@ class GenericField extends HTMLElement {
|
||||
|
||||
input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #f05a28 !important;
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: #555;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
width: 50%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button[value="submit"], button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button[value="submit"]:hover, button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -133,17 +154,13 @@ class GenericField extends HTMLElement {
|
||||
}
|
||||
|
||||
.valid {
|
||||
border: 1px solid green;
|
||||
color: green;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #006400;
|
||||
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 3px solid red;
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// The following JavaScript code defines a custom HTML element `<html-frame>` that loads and displays HTML content from a specified URL. If the URL is provided as a markdown file, it attempts to render it as HTML.
|
||||
|
||||
102
src/snek/static/inputs.css
Normal file
102
src/snek/static/inputs.css
Normal 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
100
src/snek/static/lists.css
Normal 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
98
src/snek/static/logger.js
Normal 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 };
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This JavaScript class defines a custom HTML element <markdown-frame> that fetches and loads content from a specified URL into a shadow DOM.
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines custom web components to create and interact with a tile grid system for displaying images, along with an upload button to facilitate image additions.
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// This class defines a custom HTML element that displays a list of messages with avatars and timestamps. It handles message addition with a delay in event dispatch and ensures the display of messages in the correct format.
|
||||
|
||||
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
|
||||
|
||||
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
||||
import { app } from "./app.js";
|
||||
|
||||
const LONG_TIME = 1000 * 60 * 20;
|
||||
@ -162,12 +157,10 @@ class MessageList extends HTMLElement {
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
// End-of-messages marker
|
||||
this.endOfMessages = document.createElement('div');
|
||||
this.endOfMessages.classList.add('message-list-bottom');
|
||||
this.prepend(this.endOfMessages);
|
||||
|
||||
// Observe existing children and index by uid
|
||||
for (const c of this.children) {
|
||||
this._observer.observe(c);
|
||||
if (c instanceof MessageElement) {
|
||||
@ -175,19 +168,30 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up socket events
|
||||
app.ws.addEventListener("update_message_text", (data) => {
|
||||
this._updateMessageHandler = (data) => {
|
||||
if (this.messageMap.has(data.uid)) {
|
||||
this.upsertMessage(data);
|
||||
}
|
||||
});
|
||||
app.ws.addEventListener("set_typing", (data) => {
|
||||
};
|
||||
this._typingHandler = (data) => {
|
||||
if (app._debug) console.debug("set_typing event received:", data);
|
||||
this.triggerGlow(data.user_uid, data.color);
|
||||
});
|
||||
};
|
||||
|
||||
app.ws.addEventListener("update_message_text", this._updateMessageHandler);
|
||||
app.ws.addEventListener("set_typing", this._typingHandler);
|
||||
|
||||
this.scrollToBottom(true);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
app.ws.removeEventListener("update_message_text", this._updateMessageHandler);
|
||||
app.ws.removeEventListener("set_typing", this._typingHandler);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', (e) => {
|
||||
if (
|
||||
@ -256,8 +260,8 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
|
||||
triggerGlow(uid, color) {
|
||||
if (!uid || !color) return;
|
||||
app.starField.glowColor(color);
|
||||
if (color && app.starField) app.starField.glowColor(color);
|
||||
if (!uid) return;
|
||||
let lastElement = null;
|
||||
this.querySelectorAll('.avatar').forEach((el) => {
|
||||
const anchor = el.closest('a');
|
||||
@ -285,12 +289,6 @@ class MessageList extends HTMLElement {
|
||||
|
||||
upsertMessage(data) {
|
||||
let message = this.messageMap.get(data.uid);
|
||||
if (message && (data.is_final || !data.message)) {
|
||||
//message.parentElement?.removeChild(message);
|
||||
// TO force insert
|
||||
//message = null;
|
||||
|
||||
}
|
||||
if(message && !data.message){
|
||||
message.parentElement?.removeChild(message);
|
||||
message = null;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines a class 'MessageModel' representing a message entity with various properties such as user and channel IDs, message content, and timestamps. It includes a constructor to initialize these properties.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user