Compare commits

..

No commits in common. "main" and "bugfix/multiple-issues-with-new-chat" have entirely different histories.

95 changed files with 2761 additions and 4353 deletions

View File

@ -39,9 +39,7 @@ dependencies = [
"Pillow",
"pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk",
"bcrypt"
"bleach"
]
[tool.setuptools.packages.find]
@ -52,10 +50,3 @@ where = ["src"] # <-- this changed
[project.scripts]
snek = "snek.__main__:main"
[project.optional-dependencies]
test = [
"pytest",
"pytest-asyncio",
"pytest-aiohttp"
]

View File

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

View File

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

View File

@ -1,7 +1,3 @@
import logging
logging.basicConfig(level=logging.INFO)
import pathlib
import shutil
import sqlite3
@ -13,8 +9,6 @@ from snek.shell import Shell
from snek.app import Application
@click.group()
def cli():
pass
@ -128,12 +122,6 @@ def shell(db_path):
Shell(db_path).run()
def main():
try:
import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError:
print("Could not import sentry_sdk")
cli()

View File

@ -13,9 +13,6 @@ 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
import IP2Location
from aiohttp import web
@ -34,6 +31,7 @@ from snek.sgit import GitApplication
from snek.sssh import start_ssh_server
from snek.system import http
from snek.system.cache import Cache
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler
@ -43,7 +41,6 @@ from snek.system.template import (
PythonExtension,
sanitize_html,
)
from snek.view.new import NewView
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
@ -73,13 +70,6 @@ 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
@ -136,23 +126,12 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication):
async def create_default_forum(self, app):
# Check if any forums exist
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"],
)
print("Default forum 'General Discussion' created.")
def __init__(self, *args, **kwargs):
middlewares = [
stats_middleware,
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware,
]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
@ -191,12 +170,16 @@ class Application(BaseApplication):
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
)
self.on_startup.append(self.prepare_stats)
self.on_startup.append(self.prepare_asyncio)
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)
async def prepare_stats(self, app):
app['stats'] = create_stats_structure()
print("Stats prepared", flush=True)
@property
def uptime_seconds(self):
@ -286,8 +269,6 @@ class Application(BaseApplication):
name="static",
show_index=True,
)
self.router.add_view("/new.html", NewView)
self.router.add_view("/profiler.html", profiler_handler)
self.router.add_view("/container/sock/{channel_uid}.json", ContainerView)
self.router.add_view("/about.html", AboutHTMLView)
@ -306,9 +287,9 @@ 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("/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)
@ -316,28 +297,28 @@ 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.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/{rel_path:.*}", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_get("/stats.html", stats_handler)
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
@ -354,14 +335,6 @@ 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(
@ -398,8 +371,6 @@ class Application(BaseApplication):
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
start_time = time.perf_counter()
channels = []
if not context:
context = {}
@ -460,12 +431,10 @@ class Application(BaseApplication):
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)
return rendered
async def static_handler(self, request):
file_name = request.match_info.get("filename", "")

View File

@ -78,7 +78,29 @@ class ForumApplication(aiohttp.web.Application):
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app

View File

@ -12,7 +12,6 @@ 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
@ -37,7 +36,6 @@ def get_mappers(app=None):
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
"profile_page": ProfilePageMapper(app=app),
}
)

View File

@ -1,9 +0,0 @@
import logging
from snek.model.profile_page import ProfilePageModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class ProfilePageMapper(BaseMapper):
table_name = "profile_page"
model_class = ProfilePageModel

View File

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

View File

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

View File

@ -7,6 +7,4 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
description = ModelField(name="description", required=False, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)

View File

@ -4,4 +4,4 @@ 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="value", required=True, kind=str)
value = ModelField(name="path", required=True, kind=str)

View File

@ -1,6 +1,41 @@
import json
import asyncio
import aiohttp
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
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
)
class DatasetTable:
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
class WebSocketClient2:
def __init__(self, uri):
self.uri = uri
@ -12,21 +47,196 @@ class WebSocketClient2:
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()
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())
import websockets
class DatasetWrapper(object):
def __init__(self):
self.ws = WebSocketClient()
def begin(self):
self.call(None, 'begin')
def commit(self):
self.call(None, 'commit')
def __getitem__(self, name):
return DatasetTable(self, name)
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
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
setattr(self, "db", self.get)
setattr(self, "db", self.set)
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
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
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())

View File

@ -1,33 +1,32 @@
CREATE TABLE user (
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 (
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 ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE channel (
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS 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,
@ -38,8 +37,8 @@ CREATE TABLE channel (
updated_at TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE channel_member (
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
@ -55,31 +54,28 @@ CREATE TABLE channel_member (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE channel_message (
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 (
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 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 (
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
@ -92,38 +88,11 @@ CREATE TABLE notification (
user_uid TEXT,
PRIMARY KEY (id)
);
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 (
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository (
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,
@ -131,19 +100,4 @@ CREATE TABLE repository (
user_uid TEXT,
PRIMARY KEY (id)
);
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);
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);

View File

@ -19,7 +19,6 @@ 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):
@ -63,5 +62,4 @@ register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)

View File

@ -1,5 +1,4 @@
from snek.system.service import BaseService
from snek.system.model import now
class ChannelMemberService(BaseService):
@ -9,7 +8,6 @@ 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):

View File

@ -1,21 +1,6 @@
from snek.system.service import BaseService
from snek.system.template import sanitize_html
from snek.system.template import whitelist_attributes
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader
global jinja2_env
import pathlib
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template =jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
@ -23,26 +8,9 @@ class ChannelMessageService(BaseService):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._configured_indexes = False
self._executor_pools = {}
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))
return self._executor_pools[uid]
def delete_executor(self, uid):
if uid in self._executor_pools:
self._executor_pools[uid].shutdown()
del self._executor_pools[uid]
async def maintenance(self):
args = {}
return
for message in self.mapper.db["channel_message"].find():
print(message)
try:
@ -101,14 +69,10 @@ class ChannelMessageService(BaseService):
"color": user["color"],
}
)
loop = asyncio.get_event_loop()
try:
context = json.loads(json.dumps(context, default=str))
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'])
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
except Exception as ex:
print(ex, flush=True)
@ -127,8 +91,6 @@ class ChannelMessageService(BaseService):
["deleted_at"], unique=False
)
self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
@ -165,15 +127,10 @@ class ChannelMessageService(BaseService):
"color": user["color"],
}
)
context = json.loads(json.dumps(context, default=str))
loop = asyncio.get_event_loop()
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 model['is_final']:
self.delete_executor(model['uid'])
return result
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)

View File

@ -4,8 +4,6 @@ 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):
@ -44,12 +42,10 @@ 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 inspect.iscoroutinefunction(listener):
if hasattr(listener, "__await__"): # async function or coro
await listener(event_name, data)
else:
result = listener(event_name, data)
if inspect.isawaitable(result):
await result
else: # plain sync function
listener(event_name, data)
async def notify(self, event_name: str, data: Any) -> None:
"""

View File

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

View File

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

View File

@ -44,29 +44,19 @@ class SocketService(BaseService):
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)
async def add(self, ws, user_uid):
@ -87,7 +77,7 @@ class SocketService(BaseService):
async def send_to_user(self, user_uid, message):
count = 0
for s in list(self.users.get(user_uid, [])):
for s in self.users.get(user_uid, []):
if await s.send_json(message):
count += 1
return count

View File

@ -15,7 +15,6 @@ class UserPropertyService(BaseService):
},
["user_uid", "name"],
)
self.mapper.db.commit()
async def get(self, user_uid, name):
try:

View File

@ -16,72 +16,61 @@ 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("/{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),
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),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None, None, None
return None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
request["auth_user"] = await self.parent.services.user.authenticate(
request["user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
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
if not request["user"]:
return None, None
request["repository_path"] = (
await self.parent.services.user.get_repository_path(target_user["uid"])
await self.parent.services.user.get_repository_path(request["user"]["uid"])
)
return request["auth_user"]["username"], target_user, request["repository_path"]
return request["user"]["username"], request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, target_user, repository_path = await self.check_basic_auth(request)
if not username or not target_user or not repository_path:
username, repository_path = await self.check_basic_auth(request)
if not username 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)
@ -98,17 +87,9 @@ class GitApplication(web.Application):
@require_auth
async def create_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
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)
@ -116,7 +97,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 {auth_user['username']}")
logger.info(f"Created repository: {repo_name} for 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)}")
@ -124,22 +105,16 @@ class GitApplication(web.Application):
@require_auth
async def delete_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
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 {auth_user['username']}")
logger.info(f"Deleted repository: {repo_name} for 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)}")
@ -147,20 +122,9 @@ class GitApplication(web.Application):
@require_auth
async def clone_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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
@ -175,16 +139,9 @@ class GitApplication(web.Application):
@require_auth
async def push_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
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
@ -218,21 +175,14 @@ 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 {auth_user['username']}")
logger.info(f"Pushed to repository: {repo_name} for user {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
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
@ -260,7 +210,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 {auth_user['username']}"
f"Pulled to repository {repo_name} from {remote_url} for user {username}"
)
return web.Response(
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
@ -271,20 +221,9 @@ class GitApplication(web.Application):
@require_auth
async def status_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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
@ -352,20 +291,9 @@ class GitApplication(web.Application):
@require_auth
async def list_branches(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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
@ -378,17 +306,9 @@ class GitApplication(web.Application):
@require_auth
async def create_branch(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
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
@ -408,7 +328,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 {auth_user['username']}"
f"Created branch {branch_name} in repository {repo_name} for user {username}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
@ -419,20 +339,9 @@ class GitApplication(web.Application):
@require_auth
async def commit_log(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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
@ -474,22 +383,11 @@ class GitApplication(web.Application):
@require_auth
async def file_content(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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
@ -535,42 +433,25 @@ class GitApplication(web.Application):
@require_auth
async def git_smart_http(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
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} for user: {path_username}, repo: {repo_name}, "
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
)
logger.info(f"Resolved repo path: {repo_dir}")
return repo_dir
async def handle_info_refs(service):

View File

@ -65,7 +65,7 @@ export class Chat extends EventHandler {
}
return new Promise((resolve) => {
this._waitConnect = resolve;
//console.debug("Connecting..");
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
@ -142,7 +142,7 @@ export class NotificationAudio {
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
//console.debug("Gave sound notification");
console.debug("Gave sound notification");
})
.catch((error) => {
console.error("Notification failed:", error);

View File

@ -66,13 +66,11 @@ header .logo {
}
header nav a {
color: #888;
color: #aaa;
text-decoration: none;
margin-left: 15px;
font-size: 1em;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
transition: color 0.3s;
}
.no-select {
@ -84,7 +82,6 @@ header nav a {
header nav a:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
a {
@ -410,55 +407,34 @@ a {
width: 250px;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
padding-top: 10px;
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;
font-size: 1.2em;
margin-bottom: 20px;
}
.sidebar ul {
list-style: none;
margin-bottom: 15px;
}
.sidebar ul li {
margin-bottom: 4px;
margin-bottom: 15px;
}
.sidebar ul li a {
color: #888;
color: #ccc;
text-decoration: none;
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;
font-size: 1em;
transition: color 0.3s;
}
.sidebar ul li a:hover {
color: #e6e6e6;
background-color: #1a1a1a;
border-left-color: #444;
}
.sidebar ul li a.active {
color: #f05a28;
border-left-color: #f05a28;
color: #fff;
}
@keyframes glow {
@ -573,22 +549,12 @@ dialog .dialog-actions {
}
dialog .dialog-button {
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;
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
dialog .dialog-button:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
transition: background 0.2s ease;
}
@ -614,40 +580,38 @@ dialog .dialog-button:hover {
dialog .dialog-button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
color: white;
}
dialog .dialog-button.primary:hover {
background-color: #e04924;
border-color: #e04924;
background-color: #f05a28;
}
dialog .dialog-button.secondary {
background-color: #2a2a2a;
border-color: #444;
color: #e6e6e6;
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #3a3a3a;
border-color: #555;
background-color: #f0b84c;
}
dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] {
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
/* slightly darker + lower saturation of the live colour */
background-color: #70321e; /* muted burnt orange */
color: #bfbfbf; /* light grey text */
opacity: .55; /* unified fade */
cursor: not-allowed;
pointer-events: none;
}
/* ---------- SECONDARY (yellow) ---------- */
dialog .dialog-button.secondary:disabled,
dialog .dialog-button.secondary[aria-disabled="true"] {
background-color: #0a0a0a;
border-color: #222;
color: #555;
background-color: #6c5619; /* muted mustard */
color: #bfbfbf;
opacity: .55;
cursor: not-allowed;
pointer-events: none;

View File

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

View File

@ -81,152 +81,7 @@ class ChatInputComponent extends NjetComponent {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map((mention) => {
const lowerMention = mention.toLowerCase();
let bestMatch = null;
let bestScore = 0;
for (const author of authors) {
const lowerAuthor = author.toLowerCase();
let score = 0;
if (lowerMention === lowerAuthor) {
score = 100;
} else if (lowerAuthor.startsWith(lowerMention)) {
score = 90 + (5 * (lowerMention.length / lowerAuthor.length));
} else if (lowerAuthor.includes(lowerMention) && lowerMention.length >= 2) {
const position = lowerAuthor.indexOf(lowerMention);
score = 80 - (10 * (position / lowerAuthor.length));
} else if (this.isFuzzyMatch(lowerMention, lowerAuthor)) {
const ratio = lowerMention.length / lowerAuthor.length;
score = 40 + (20 * ratio);
} else if (this.isCloseMatch(lowerMention, lowerAuthor)) {
score = 30 + (10 * (lowerMention.length / lowerAuthor.length));
}
if (score > bestScore) {
bestScore = score;
bestMatch = author;
}
}
const minScore = 40;
return {
mention,
closestAuthor: bestScore >= minScore ? bestMatch : null,
score: bestScore,
};
});
}
isFuzzyMatch(needle, haystack) {
if (needle.length < 2) return false;
let needleIndex = 0;
for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
if (haystack[i] === needle[needleIndex]) {
needleIndex++;
}
}
return needleIndex === needle.length;
}
isCloseMatch(str1, str2) {
if (Math.abs(str1.length - str2.length) > 2) return false;
const shorter = str1.length <= str2.length ? str1 : str2;
const longer = str1.length > str2.length ? str1 : str2;
let differences = 0;
let j = 0;
for (let i = 0; i < shorter.length && j < longer.length; i++) {
if (shorter[i] !== longer[j]) {
differences++;
if (j + 1 < longer.length && shorter[i] === longer[j + 1]) {
j++;
}
}
j++;
}
differences += Math.abs(longer.length - j);
return differences <= 2;
}
matchMentions4ToAuthors(mentions, authors) {
return mentions.map((mention) => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach((author) => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10;
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
if (minDistance < 5) {
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + 1
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
if(closestAuthor){
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
}
});
return updatedText;
}
matchMentions2ToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
@ -245,14 +100,53 @@ class ChatInputComponent extends NjetComponent {
closestAuthor = author;
}
});
if (minDistance < 5){
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
textToLeet(text) {
// L33t speak character mapping
const leetMap = {
@ -498,7 +392,7 @@ textToLeetAdvanced(text) {
}
j++;
}
return i === s.length && s.length > 1;
return i === s.length;
}
flagTyping() {

View File

@ -1,494 +1,226 @@
import { NjetComponent } from "/njet.js"
import { NjetComponent} from "/njet.js"
class NjetEditor extends NjetComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
class NjetEditor extends NjetComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
position: relative;
height: 100%;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
#editor {
padding: 1rem;
outline: none;
white-space: pre-wrap;
line-height: 1.5;
height: calc(100% - 30px);
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
font-size: 14px;
caret-color: #fff;
}
#editor.insert-mode {
caret-color: #4ec9b0;
}
#editor.visual-mode {
caret-color: #c586c0;
}
#editor::selection {
background: #264f78;
}
#status-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: #007acc;
color: #fff;
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 12px;
font-weight: 500;
}
#mode-indicator {
text-transform: uppercase;
margin-right: 20px;
font-weight: bold;
}
#command-line {
position: absolute;
bottom: 30px;
left: 0;
width: 100%;
padding: 0.3rem 1rem;
background: #2d2d2d;
color: #d4d4d4;
display: none;
font-family: inherit;
border-top: 1px solid #3e3e3e;
font-size: 14px;
}
#command-input {
background: transparent;
border: none;
color: inherit;
outline: none;
font-family: inherit;
font-size: inherit;
width: calc(100% - 20px);
}
.visual-selection {
background: #264f78 !important;
}
`;
const style = document.createElement('style');
style.textContent = `
#editor {
padding: 1rem;
outline: none;
white-space: pre-wrap;
line-height: 1.5;
height: 100%;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
}
#command-line {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 0.2rem 1rem;
background: #333;
color: #0f0;
display: none;
font-family: monospace;
}
`;
this.editor = document.createElement('div');
this.editor.id = 'editor';
this.editor.contentEditable = true;
this.editor.spellcheck = false;
this.editor.innerText = `Welcome to VimEditor Component
this.editor = document.createElement('div');
this.editor.id = 'editor';
this.editor.contentEditable = true;
this.editor.innerText = `Welcome to VimEditor Component
Line 2 here
Another line
Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
this.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line';
const cmdPrompt = document.createElement('span');
cmdPrompt.textContent = ':';
this.cmdInput = document.createElement('input');
this.cmdInput.id = 'command-input';
this.cmdInput.type = 'text';
this.cmdLine.append(cmdPrompt, this.cmdInput);
this.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line';
this.shadowRoot.append(style, this.editor, this.cmdLine);
this.statusBar = document.createElement('div');
this.statusBar.id = 'status-bar';
this.modeIndicator = document.createElement('span');
this.modeIndicator.id = 'mode-indicator';
this.modeIndicator.textContent = 'NORMAL';
this.statusBar.appendChild(this.modeIndicator);
this.mode = 'normal'; // normal | insert | visual | command
this.keyBuffer = '';
this.lastDeletedLine = '';
this.yankedLine = '';
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
}
this.mode = 'normal';
this.keyBuffer = '';
this.lastDeletedLine = '';
this.yankedLine = '';
this.visualStartOffset = null;
this.visualEndOffset = null;
// Bind event handlers
this.handleKeydown = this.handleKeydown.bind(this);
this.handleCmdKeydown = this.handleCmdKeydown.bind(this);
this.updateVisualSelection = this.updateVisualSelection.bind(this);
this.editor.addEventListener('keydown', this.handleKeydown);
this.cmdInput.addEventListener('keydown', this.handleCmdKeydown);
this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
}
connectedCallback() {
this.editor.focus();
}
setMode(mode) {
this.mode = mode;
this.modeIndicator.textContent = mode.toUpperCase();
// Update editor classes
this.editor.classList.remove('insert-mode', 'visual-mode', 'normal-mode');
this.editor.classList.add(`${mode}-mode`);
if (mode === 'visual') {
this.visualStartOffset = this.getCaretOffset();
this.editor.addEventListener('selectionchange', this.updateVisualSelection);
} else {
this.clearVisualSelection();
this.editor.removeEventListener('selectionchange', this.updateVisualSelection);
}
if (mode === 'command') {
this.cmdLine.style.display = 'block';
this.cmdInput.value = '';
this.cmdInput.focus();
} else {
this.cmdLine.style.display = 'none';
if (mode !== 'insert') {
// Keep focus on editor for all non-insert modes
connectedCallback() {
this.editor.focus();
}
}
}
updateVisualSelection() {
if (this.mode !== 'visual') return;
this.visualEndOffset = this.getCaretOffset();
}
clearVisualSelection() {
const sel = this.shadowRoot.getSelection();
if (sel) sel.removeAllRanges();
this.visualStartOffset = null;
this.visualEndOffset = null;
}
getCaretOffset() {
const sel = this.shadowRoot.getSelection();
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
setCaretOffset(offset) {
const textContent = this.editor.innerText;
offset = Math.max(0, Math.min(offset, textContent.length));
const range = document.createRange();
const sel = this.shadowRoot.getSelection();
const walker = document.createTreeWalker(
this.editor,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while ((node = walker.nextNode())) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= offset) {
range.setStart(node, offset - currentOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
currentOffset += nodeLength;
}
// If we couldn't find the position, set to end
if (this.editor.lastChild) {
range.selectNodeContents(this.editor.lastChild);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
}
handleBeforeInput(e) {
if (this.mode !== 'insert') {
e.preventDefault();
}
}
handleCmdKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.cmdInput.value);
this.setMode('normal');
} else if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
}
}
executeCommand(cmd) {
const trimmedCmd = cmd.trim();
// Handle basic vim commands
if (trimmedCmd === 'w' || trimmedCmd === 'write') {
console.log('Save command (not implemented)');
} else if (trimmedCmd === 'q' || trimmedCmd === 'quit') {
console.log('Quit command (not implemented)');
} else if (trimmedCmd === 'wq' || trimmedCmd === 'x') {
console.log('Save and quit command (not implemented)');
} else if (/^\d+$/.test(trimmedCmd)) {
// Go to line number
const lineNum = parseInt(trimmedCmd, 10) - 1;
this.goToLine(lineNum);
}
}
goToLine(lineNum) {
const lines = this.editor.innerText.split('\n');
if (lineNum < 0 || lineNum >= lines.length) return;
let offset = 0;
for (let i = 0; i < lineNum; i++) {
offset += lines[i].length + 1;
}
this.setCaretOffset(offset);
}
getCurrentLineInfo() {
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
return {
lineIndex: i,
lines: lines,
lineStartOffset: charCount,
positionInLine: caretPos - charCount
};
}
charCount += lines[i].length + 1;
}
return {
lineIndex: lines.length - 1,
lines: lines,
lineStartOffset: charCount - lines[lines.length - 1].length - 1,
positionInLine: 0
};
}
handleKeydown(e) {
if (this.mode === 'insert') {
if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
// Move cursor one position left (vim behavior)
const offset = this.getCaretOffset();
if (offset > 0) {
this.setCaretOffset(offset - 1);
}
}
return;
}
if (this.mode === 'command') {
return; // Command mode input is handled by cmdInput
}
if (this.mode === 'visual') {
if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
return;
}
// Allow movement in visual mode
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
return; // Let default behavior handle selection
}
if (e.key === 'y') {
e.preventDefault();
// Yank selected text
getCaretOffset() {
let caretOffset = 0;
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.yankedLine = sel.toString();
}
this.setMode('normal');
return;
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
caretOffset = preCaretRange.toString().length;
return caretOffset;
}
if (e.key === 'd' || e.key === 'x') {
e.preventDefault();
// Delete selected text
setCaretOffset(offset) {
const range = document.createRange();
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.lastDeletedLine = sel.toString();
document.execCommand('delete');
}
this.setMode('normal');
return;
}
}
const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
// Normal mode handling
e.preventDefault();
// Special keys that should be handled immediately
if (e.key === 'Escape') {
this.keyBuffer = '';
this.setMode('normal');
return;
}
// Build key buffer for commands
this.keyBuffer += e.key;
const lineInfo = this.getCurrentLineInfo();
const { lineIndex, lines, lineStartOffset, positionInLine } = lineInfo;
// Process commands
switch (this.keyBuffer) {
case 'i':
this.keyBuffer = '';
this.setMode('insert');
break;
case 'a':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
this.setMode('insert');
break;
case 'v':
this.keyBuffer = '';
this.setMode('visual');
break;
case ':':
this.keyBuffer = '';
this.setMode('command');
break;
case 'yy':
this.keyBuffer = '';
this.yankedLine = lines[lineIndex];
break;
case 'dd':
this.keyBuffer = '';
this.lastDeletedLine = lines[lineIndex];
lines.splice(lineIndex, 1);
if (lines.length === 0) lines.push('');
this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset);
break;
case 'p':
this.keyBuffer = '';
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIndex + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1);
}
break;
case '0':
this.keyBuffer = '';
this.setCaretOffset(lineStartOffset);
break;
case '$':
this.keyBuffer = '';
this.setCaretOffset(lineStartOffset + lines[lineIndex].length);
break;
case 'gg':
this.keyBuffer = '';
this.setCaretOffset(0);
break;
case 'G':
this.keyBuffer = '';
this.setCaretOffset(this.editor.innerText.length);
break;
case 'h':
case 'ArrowLeft':
this.keyBuffer = '';
const currentOffset = this.getCaretOffset();
if (currentOffset > 0) {
this.setCaretOffset(currentOffset - 1);
}
break;
case 'l':
case 'ArrowRight':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
break;
case 'j':
case 'ArrowDown':
this.keyBuffer = '';
if (lineIndex < lines.length - 1) {
const nextLineStart = lineStartOffset + lines[lineIndex].length + 1;
const nextLineLength = lines[lineIndex + 1].length;
const newPosition = Math.min(positionInLine, nextLineLength);
this.setCaretOffset(nextLineStart + newPosition);
}
break;
case 'k':
case 'ArrowUp':
this.keyBuffer = '';
if (lineIndex > 0) {
let prevLineStart = 0;
for (let i = 0; i < lineIndex - 1; i++) {
prevLineStart += lines[i].length + 1;
let currentOffset = 0;
let node;
while ((node = walker.nextNode())) {
if (currentOffset + node.length >= offset) {
range.setStart(node, offset - currentOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
const prevLineLength = lines[lineIndex - 1].length;
const newPosition = Math.min(positionInLine, prevLineLength);
this.setCaretOffset(prevLineStart + newPosition);
currentOffset += node.length;
}
break;
}
default:
// Clear buffer if it gets too long or contains invalid sequences
if (this.keyBuffer.length > 2 ||
(this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) {
this.keyBuffer = '';
handleKeydown(e) {
const key = e.key;
if (this.mode === 'insert') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
this.editor.blur();
this.editor.focus();
}
return;
}
break;
if (this.mode === 'command') {
if (key === 'Enter' || key === 'Escape') {
e.preventDefault();
this.cmdLine.style.display = 'none';
this.mode = 'normal';
this.keyBuffer = '';
}
return;
}
if (this.mode === 'visual') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
}
return;
}
// Handle normal mode
this.keyBuffer += key;
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0, lineIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
lineIdx = i;
break;
}
charCount += lines[i].length + 1;
}
const offsetToLine = idx =>
text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
switch (this.keyBuffer) {
case 'i':
e.preventDefault();
this.mode = 'insert';
this.keyBuffer = '';
break;
case 'v':
e.preventDefault();
this.mode = 'visual';
this.keyBuffer = '';
break;
case ':':
e.preventDefault();
this.mode = 'command';
this.cmdLine.style.display = 'block';
this.cmdLine.textContent = ':';
this.keyBuffer = '';
break;
case 'yy':
e.preventDefault();
this.yankedLine = lines[lineIdx];
this.keyBuffer = '';
break;
case 'dd':
e.preventDefault();
this.lastDeletedLine = lines[lineIdx];
lines.splice(lineIdx, 1);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case 'p':
e.preventDefault();
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIdx + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx + 1));
}
this.keyBuffer = '';
break;
case '0':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case '$':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length);
this.keyBuffer = '';
break;
case 'gg':
e.preventDefault();
this.setCaretOffset(0);
this.keyBuffer = '';
break;
case 'G':
e.preventDefault();
this.setCaretOffset(text.length);
this.keyBuffer = '';
break;
case 'Escape':
e.preventDefault();
this.mode = 'normal';
this.keyBuffer = '';
this.cmdLine.style.display = 'none';
break;
default:
// allow up to 2 chars for combos
if (this.keyBuffer.length > 2) this.keyBuffer = '';
break;
}
}
}
}
}
customElements.define('njet-editor', NjetEditor);
export { NjetEditor }
customElements.define('njet-editor', NjetEditor);
export {NjetEditor}

View File

@ -3,15 +3,8 @@ export class EventHandler {
this.subscribers = {};
}
addEventListener(type, handler, { once = false } = {}) {
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
if (once) {
const originalHandler = handler;
handler = (...args) => {
originalHandler(...args);
this.removeEventListener(type, handler);
};
}
this.subscribers[type].push(handler);
}
@ -19,15 +12,4 @@ export class EventHandler {
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
}
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];
}
}
}

View File

@ -28,30 +28,21 @@ class FancyButton extends HTMLElement {
button {
width: var(--width);
min-width: ${size};
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;
padding: 10px;
background-color: #f05a28;
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.3s;
border: 1px solid #f05a28;
}
button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
button.primary:hover {
color: #EFEFEF;
background-color: #e04924;
border-color: #e04924;
border: 1px solid #efefef;
}
`;

View File

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

View File

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

View File

@ -1,102 +0,0 @@
.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;
}

View File

@ -1,100 +0,0 @@
.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;
}
}

View File

@ -5,71 +5,75 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "./app.js";
import {app} from "./app.js";
const LONG_TIME = 1000 * 60 * 20;
const LONG_TIME = 1000 * 60 * 20
export class ReplyEvent extends Event {
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
// Clone and sanitize message node to text-only reply
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0"
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
// Remove all .embed-url-link
newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
newMessage.querySelectorAll('.embed-url-link').forEach(link => {
link.remove()
})
// Replace <picture> with their <img>
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) picture.replaceWith(img);
});
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) {
picture.replaceWith(img);
}
})
// Replace <img> with just their src
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
});
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
})
// Replace <video> with just their src
newMessage.querySelectorAll('video').forEach(vid => {
const src = vid.src || vid.currentSrc || vid.querySelector('source').src;
vid.replaceWith(document.createTextNode(src));
});
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
})
// Replace <iframe> with their src
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
});
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
})
// Replace <a> with href or markdown
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
});
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove();
}
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove()
}
}
class MessageElement extends HTMLElement {
// static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
isVisible() {
if (!this) return false;
const rect = this.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
updateUI() {
if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid } = this.dataset;
const { color, user_nick, created_at, user_uid} = this.dataset;
this.classList.add('message');
this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children);
this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
@ -79,30 +83,29 @@ class MessageElement extends HTMLElement {
<div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}">
<span></span>
<a href="#reply">reply</a>
<a href="#reply">reply</a></div>
</div>
</div>
`;
`;
this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child);
});
this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child);
});
}
this.timeDiv = this.querySelector('.time span');
this.replyDiv = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
});
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
})
}
// Sibling logic for user switches and long time gaps
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
this.siblingGenerated = this.nextElementSibling;
if (!this.siblingGenerated && this.nextElementSibling) {
this.siblingGenerated = true;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
this.classList.add('switch-user');
} else {
@ -110,7 +113,7 @@ class MessageElement extends HTMLElement {
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
const currentTime = new Date(this.dataset.created_at);
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
this.classList.add('long-time');
} else {
this.classList.remove('long-time');
@ -123,7 +126,7 @@ class MessageElement extends HTMLElement {
updateMessage(...messages) {
if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages);
this.messageDiv.replaceChildren(...messages)
this._originalChildren = messages;
}
}
@ -132,26 +135,38 @@ class MessageElement extends HTMLElement {
this.updateUI();
}
disconnectedCallback() {}
connectedMoveCallback() {}
disconnectedCallback() {
}
connectedMoveCallback() {
}
attributeChangedCallback(name, oldValue, newValue) {
this.updateUI();
this.updateUI()
}
}
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
this.messageMap = new Map();
this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.visibleSet.add(entry.target);
if (entry.target instanceof MessageElement) {
entry.target.updateUI();
const messageElement = entry.target;
if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
} else {
this.visibleSet.delete(entry.target);
@ -160,42 +175,28 @@ class MessageList extends HTMLElement {
}, {
root: this,
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) {
for(const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c);
this.messageMap.set(c.dataset.uid, c);
}
}
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color);
});
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
this.scrollToBottom(true);
}
connectedCallback() {
this.addEventListener('click', (e) => {
if (
e.target.tagName !== 'IMG' ||
e.target.classList.contains('avatar-img')
) return;
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
@ -216,11 +217,12 @@ class MessageList extends HTMLElement {
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
});
// ESC to close
// Optional: ESC key closes overlay
const escListener = (evt) => {
if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
@ -228,9 +230,8 @@ class MessageList extends HTMLElement {
}
};
document.addEventListener('keydown', escListener);
});
})
}
isElementVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
@ -241,16 +242,14 @@ class MessageList extends HTMLElement {
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
isScrolledToBottom() {
return this.visibleSet.has(this.endOfMessages);
return this.isElementVisible(this.endOfMessages);
}
scrollToBottom(force = false, behavior = 'instant') {
scrollToBottom(force = false, behavior= 'instant') {
if (force || !this.isScrolledToBottom()) {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
}, 200);
}
}
@ -262,9 +261,7 @@ class MessageList extends HTMLElement {
this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a');
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
if(!lastElement)
lastElement = el;
lastElement = el;
}
});
if (lastElement) {
@ -275,48 +272,40 @@ class MessageList extends HTMLElement {
}
}
updateTimes() {
this.visibleSet.forEach((messageElement) => {
if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
});
if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
})
}
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) {
message.parentElement?.removeChild(message);
}
if(message && !data.message){
message.parentElement?.removeChild(message);
message = null;
}
if (!data.message) return;
if (!data.message) return
const wrapper = document.createElement("div");
wrapper.innerHTML = data.html;
if (message) {
// If the old element is already custom, only update its message children
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else {
// If not, insert the new one and observe
message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message);
this._observer.observe(message);
this.endOfMessages.after(message);
}
const scrolledToBottom = this.isScrolledToBottom();
this.prepend(message);
if (scrolledToBottom) this.scrollToBottom(true);
}
}
customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList);

View File

@ -1,3 +1,5 @@
class RestClient {
constructor({ baseURL = '', headers = {} } = {}) {
this.baseURL = baseURL;
@ -208,52 +210,27 @@ class Njet extends HTMLElement {
customElements.define(name, component);
}
constructor(config) {
constructor() {
super();
// Store the config for use in render and other methods
this.config = config || {};
if (!Njet._root) {
Njet._root = this
Njet._rest = new RestClient({ baseURL: '/' || null })
}
this.root._elements.push(this)
this.classList.add('njet');
// Initialize properties from config before rendering
this.initProps(this.config);
// Call render after properties are initialized
this.render.call(this);
// Call construct if defined
if (typeof this.config.construct === 'function') {
this.config.construct.call(this)
}
//this.initProps(config);
//if (typeof this.config.construct === 'function')
// this.config.construct.call(this)
}
initProps(config) {
const props = Object.keys(config)
props.forEach(prop => {
// Skip special properties that are handled separately
if (['construct', 'items', 'classes'].includes(prop)) {
return;
}
// Check if there's a setter for this property
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), prop);
if (descriptor && descriptor.set) {
// Use the setter
if (config[prop] !== undefined) {
this[prop] = config[prop];
} else if (prop in this) {
// Property exists, set it directly
this[prop] = config[prop];
} else {
// Set as attribute for unknown properties
this.setAttribute(prop, config[prop]);
}
});
if (config.classes) {
this.classList.add(...config.classes);
}
@ -365,7 +342,7 @@ class NjetDialog extends Component {
const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px';
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.justifyContent = 'flenjet-end';
buttonContainer.style.gap = '10px';
if (secondaryButton) {
const secondary = new NjetButton(secondaryButton);
@ -395,9 +372,8 @@ class NjetWindow extends Component {
header.textContent = title;
this.appendChild(header);
}
if (this.config.items) {
this.config.items.forEach(item => this.appendChild(item));
}
this.config.items.forEach(item => this.appendChild(item));
}
show(){
@ -432,8 +408,7 @@ class NjetGrid extends Component {
}
}
Njet.registerComponent('njet-grid', NjetGrid);
/* Example usage:
/*
const button = new NjetButton({
classes: ['my-button'],
text: 'Shared',
@ -518,7 +493,7 @@ document.body.appendChild(dialog);
*/
class NjetComponent extends Component {}
const njet = Njet
const njet = Njet
njet.showDialog = function(args){
const dialog = new NjetDialog(args)
dialog.show()
@ -570,16 +545,15 @@ njet.showWindow = function(args) {
return w
}
njet.publish = function(event, data) {
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) {
if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data))
}
}
njet.subscribe = function(event, callback) {
if (!this.root) return;
if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = []
}
this.root._subscriptions[event].push(callback)
}
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus };
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus };

View File

@ -1,10 +1,13 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.registration-container {
}
.registration-container {
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
@ -12,90 +15,63 @@
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
left: calc(50%-200);
}
.registration-container h1 {
}
.registration-container h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
}
.registration-container input {
}
.registration-container input {
width: 100%;
padding: 10px 12px;
padding: 10px;
margin: 10px 0;
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;
}
.registration-container input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
.registration-container input::placeholder {
color: #555;
}
.registration-container button {
width: 100%;
padding: 10px 20px;
border-radius: 5px;
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: all 0.2s ease;
}
.registration-container button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
.registration-container button[type="submit"],
.registration-container button.primary {
font-size: 1em;
}
.registration-container button {
width: 100%;
padding: 10px;
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
.registration-container button[type="submit"]:hover,
.registration-container button.primary:hover {
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.registration-container button:hover {
background-color: #e04924;
border-color: #e04924;
}
.registration-container a {
}
.registration-container a {
color: #f05a28;
text-decoration: none;
display: block;
margin-top: 15px;
font-size: 0.9em;
transition: color 0.3s;
}
.registration-container a:hover {
}
.registration-container a:hover {
color: #e04924;
}
.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
@media (max-width: 500px) {
}
.error {
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
}
@media (max-width: 500px) {
.registration-container {
width: 90%;
width: 90%;
}
}
}

View File

@ -52,7 +52,7 @@ self.addEventListener("push", async (event) => {
data,
}).then(e => console.log("Showing notification", e)).catch(console.error);
// event.waitUntil(reg);
event.waitUntil(reg);
});

View File

@ -86,7 +86,6 @@ export class Socket extends EventHandler {
}
this.emit("data", data.data);
if (data["event"]) {
console.info([data.event,data.data])
this.emit(data.event, data.data);
}
}
@ -100,7 +99,7 @@ export class Socket extends EventHandler {
console.log("Reconnecting");
this.emit("reconnecting");
return this.connect();
}, 4000);
}, 0);
}
_camelToSnake(str) {
@ -143,9 +142,10 @@ export class Socket extends EventHandler {
method,
args,
};
const me = this;
return new Promise((resolve) => {
this.addEventListener(call.callId, (data) => resolve(data), { once: true});
this.sendJson(call);
me.addEventListener(call.callId, (data) => resolve(data));
me.sendJson(call);
});
}
}

View File

@ -1,22 +1,135 @@
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
setattr(self, "db", self.get)
setattr(self, "db", self.set)
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
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
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
class BroadCastSocketView:
def __init__(self):
self.ws = None
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
app = request.app
app['broadcast_clients'].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
print(msg.data)
for client in app['broadcast_clients'] if not client == ws:
await client.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
app['broadcast_clients'].remove(ws)
return ws
app = web.Application()
view = DatasetWebSocketView()
app['broadcast_clients'] = []
app.router.add_get('/db', view.handle)
app.router.add_get('/broadcast', sync_view.handle)

View File

@ -1,137 +1,75 @@
import asyncio
import functools
import json
from collections import OrderedDict
from snek.system import security
cache = functools.cache
CACHE_MAX_ITEMS_DEFAULT = 5000
class Cache:
"""
An asynchronous, thread-safe, in-memory LRU (Least Recently Used) cache.
This implementation uses an OrderedDict for efficient O(1) time complexity
for its core get, set, and delete operations.
"""
def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):
self.app = app
# OrderedDict is the core of the LRU logic. It remembers the order
# in which items were inserted.
self.cache: OrderedDict = OrderedDict()
self.cache = {}
self.max_items = max_items
self.stats = {}
self.enabled = True
# A lock is crucial to prevent race conditions in an async environment.
self._lock = asyncio.Lock()
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
async def get(self, key):
"""
Retrieves an item from the cache. If found, it's marked as recently used.
Returns None if the item is not found or the cache is disabled.
"""
async def get(self, args):
if not self.enabled:
return None
#async with self._lock:
if key not in self.cache:
await self.update_stat(key, "get")
await self.update_stat(args, "get")
try:
self.lru.pop(self.lru.index(args))
except:
# print("Cache miss!", args, flush=True)
return None
# Mark as recently used by moving it to the end of the OrderedDict.
# This is an O(1) operation.
self.cache.move_to_end(key)
await self.update_stat(key, "get")
return self.cache[key]
async def set(self, key, value):
"""
Adds or updates an item in the cache and marks it as recently used.
If the cache exceeds its maximum size, the least recently used item is evicted.
"""
if not self.enabled:
return
# comment
#async with self._lock:
is_new = key not in self.cache
# Add or update the item. If it exists, it's moved to the end.
self.cache[key] = value
self.cache.move_to_end(key)
await self.update_stat(key, "set")
# Evict the least recently used item if the cache is full.
# This is an O(1) operation.
if len(self.cache) > self.max_items:
# popitem(last=False) removes and returns the first (oldest) item.
evicted_key, _ = self.cache.popitem(last=False)
# Optionally, you could log the evicted key here.
if is_new:
self.version += 1
async def delete(self, key):
"""Removes an item from the cache if it exists."""
if not self.enabled:
return
async with self._lock:
if key in self.cache:
await self.update_stat(key, "delete")
# Deleting from OrderedDict is an O(1) operation on average.
del self.cache[key]
self.lru.insert(0, args)
while len(self.lru) > self.max_items:
self.cache.pop(self.lru[-1])
self.lru.pop()
# print("Cache hit!", args, flush=True)
return self.cache[args]
async def get_stats(self):
"""Returns statistics for all items currently in the cache."""
async with self._lock:
stats_list = []
# Items are iterated from oldest to newest. We reverse to show
# most recently used items first.
for key in reversed(self.cache):
stat_data = self.stats.get(key, {"set": 0, "get": 0, "delete": 0})
value = self.cache[key]
value_record = value.record if hasattr(value, 'record') else value
stats_list.append({
all_ = []
for key in self.lru:
all_.append(
{
"key": key,
"set": stat_data.get("set", 0),
"get": stat_data.get("get", 0),
"delete": stat_data.get("delete", 0),
"value": str(self.serialize(value_record)),
})
return stats_list
async def update_stat(self, key, action):
"""Updates hit/miss/set counts for a given cache key."""
# This method is already called within a locked context,
# but the lock makes it safe if ever called directly.
async with self._lock:
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] += 1
"set": self.stats[key]["set"],
"get": self.stats[key]["get"],
"delete": self.stats[key]["delete"],
"value": str(self.serialize(self.cache[key].record)),
}
)
return all_
def serialize(self, obj):
"""A synchronous helper to create a serializable representation of an object."""
if not isinstance(obj, dict):
return obj
cpy = obj.copy()
for key_to_remove in ["created_at", "deleted_at", "email", "password"]:
cpy.pop(key_to_remove, None)
cpy.pop("created_at", None)
cpy.pop("deleted_at", None)
cpy.pop("email", None)
cpy.pop("password", None)
return cpy
async def update_stat(self, key, action):
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] = self.stats[key][action] + 1
def json_default(self, value):
"""JSON serializer fallback for objects that are not directly serializable."""
# if hasattr(value, "to_json"):
# return value.to_json()
try:
return json.dumps(value.__dict__, default=str)
except:
return str(value)
async def create_cache_key(self, args, kwargs):
"""Creates a consistent, hashable cache key from function arguments."""
# security.hash is async, so this method remains async.
return await security.hash(
json.dumps(
{"args": args, "kwargs": kwargs},
@ -140,8 +78,38 @@ class Cache:
)
)
async def set(self, args, result):
if not self.enabled:
return
is_new = args not in self.cache
self.cache[args] = result
await self.update_stat(args, "set")
try:
self.lru.pop(self.lru.index(args))
except (ValueError, IndexError):
pass
self.lru.insert(0, args)
while len(self.lru) > self.max_items:
self.cache.pop(self.lru[-1])
self.lru.pop()
if is_new:
self.version += 1
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
async def delete(self, args):
if not self.enabled:
return
await self.update_stat(args, "delete")
if args in self.cache:
try:
self.lru.pop(self.lru.index(args))
except IndexError:
pass
del self.cache[args]
def async_cache(self, func):
"""Decorator to cache the results of an async function."""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
cache_key = await self.create_cache_key(args, kwargs)
@ -151,14 +119,33 @@ class Cache:
result = await func(*args, **kwargs)
await self.set(cache_key, result)
return result
return wrapper
def async_delete_cache(self, func):
"""Decorator to invalidate a cache entry before running an async function."""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
cache_key = await self.create_cache_key(args, kwargs)
await self.delete(cache_key)
if cache_key in self.cache:
try:
self.lru.pop(self.lru.index(cache_key))
except IndexError:
pass
del self.cache[cache_key]
return await func(*args, **kwargs)
return wrapper
def async_cache(func):
cache = {}
@functools.wraps(func)
async def wrapper(*args):
if args in cache:
return cache[args]
result = await func(*args)
cache[args] = result
return result
return wrapper

View File

@ -1,108 +0,0 @@
import asyncio
import functools
import inspect
import logging
import traceback
from typing import Any, Callable
logger = logging.getLogger(__name__)
def debug_trace(func: Callable) -> Callable:
func_logger = logging.getLogger(func.__module__)
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
func_name = func.__qualname__
args_repr = _format_args(args, kwargs)
func_logger.debug(f"→ ENTER {func_name}({args_repr})")
try:
result = await func(*args, **kwargs)
result_repr = _format_result(result)
func_logger.debug(f"← EXIT {func_name} = {result_repr}")
return result
except Exception as e:
func_logger.error(
f"✗ EXCEPTION in {func_name}({args_repr}): {type(e).__name__}: {e}\n"
f"{''.join(traceback.format_exception(type(e), e, e.__traceback__))}"
)
raise
return async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
func_name = func.__qualname__
args_repr = _format_args(args, kwargs)
func_logger.debug(f"→ ENTER {func_name}({args_repr})")
try:
result = func(*args, **kwargs)
result_repr = _format_result(result)
func_logger.debug(f"← EXIT {func_name} = {result_repr}")
return result
except Exception as e:
func_logger.error(
f"✗ EXCEPTION in {func_name}({args_repr}): {type(e).__name__}: {e}\n"
f"{''.join(traceback.format_exception(type(e), e, e.__traceback__))}"
)
raise
return sync_wrapper
def _format_args(args: tuple, kwargs: dict) -> str:
args_parts = []
for i, arg in enumerate(args):
if i == 0 and hasattr(arg, '__class__') and arg.__class__.__name__ in ['Application', 'BaseService', 'BaseView', 'BaseFormView']:
args_parts.append(f"self={arg.__class__.__name__}")
else:
args_parts.append(_repr_value(arg))
for key, value in kwargs.items():
args_parts.append(f"{key}={_repr_value(value)}")
return ", ".join(args_parts)
def _format_result(result: Any) -> str:
return _repr_value(result)
def _repr_value(value: Any, max_len: int = 200) -> str:
if value is None:
return "None"
elif isinstance(value, (str, int, float, bool)):
repr_str = repr(value)
elif isinstance(value, dict):
if len(value) == 0:
return "{}"
keys = list(value.keys())[:3]
items = [f"{k!r}: {_repr_value(value[k], 50)}" for k in keys]
suffix = "..." if len(value) > 3 else ""
repr_str = "{" + ", ".join(items) + suffix + "}"
elif isinstance(value, (list, tuple)):
type_name = type(value).__name__
if len(value) == 0:
return f"{type_name}()"
items = [_repr_value(v, 50) for v in value[:3]]
suffix = "..." if len(value) > 3 else ""
repr_str = f"{type_name}([{', '.join(items)}{suffix}])"
else:
repr_str = f"<{type(value).__name__}>"
if len(repr_str) > max_len:
return repr_str[:max_len] + "..."
return repr_str
def apply_debug_decorators(cls, debug_enabled: bool = False):
if not debug_enabled:
return cls
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
if name.startswith('_') and name != '__init__':
continue
if name in ['__repr__', '__str__', '__eq__', '__hash__']:
continue
setattr(cls, name, debug_trace(method))
return cls

View File

@ -1,55 +0,0 @@
import logging
import traceback
from aiohttp import web
logger = logging.getLogger(__name__)
@web.middleware
async def exception_handler_middleware(request, handler):
try:
response = await handler(request)
return response
except web.HTTPException as ex:
raise
except Exception as e:
debug_mode = hasattr(request.app, 'debug') and request.app.debug
error_id = id(e)
error_msg = f"Internal Server Error (ID: {error_id})"
if debug_mode:
stack_trace = ''.join(traceback.format_exception(type(e), e, e.__traceback__))
logger.error(
f"✗ UNHANDLED EXCEPTION in {request.method} {request.path}\n"
f"Error ID: {error_id}\n"
f"Exception: {type(e).__name__}: {e}\n"
f"Request: {request.method} {request.url}\n"
f"Headers: {dict(request.headers)}\n"
f"Stack trace:\n{stack_trace}"
)
return web.json_response(
{
"error": error_msg,
"exception": f"{type(e).__name__}: {str(e)}",
"path": str(request.path),
"method": request.method,
"error_id": error_id,
"stack_trace": stack_trace.split('\n')
},
status=500
)
else:
logger.error(
f"✗ UNHANDLED EXCEPTION in {request.method} {request.path}\n"
f"Error ID: {error_id}\n"
f"Exception: {type(e).__name__}: {e}"
)
return web.json_response(
{
"error": error_msg,
"error_id": error_id
},
status=500
)

View File

@ -1,20 +0,0 @@
class SnekException(Exception):
def __init__(self, message: str, details: dict = None):
super().__init__(message)
self.message = message
self.details = details or {}
class ValidationError(SnekException):
pass
class NotFoundError(SnekException):
pass
class PermissionDeniedError(SnekException):
pass
class DuplicateResourceError(SnekException):
pass
class AuthenticationError(SnekException):
pass

View File

@ -1,7 +1,7 @@
DEFAULT_LIMIT = 30
import asyncio
import typing
import time
from snek.system.model import BaseModel
@ -15,6 +15,8 @@ class BaseMapper:
def __init__(self, app):
self.app = app
self.default_limit = self.__class__.default_limit
@property
def db(self):
return self.app.db
@ -25,7 +27,6 @@ class BaseMapper:
async def run_in_executor(self, func, *args, **kwargs):
use_semaphore = kwargs.pop("use_semaphore", False)
start_time = time.time()
def _execute():
result = func(*args, **kwargs)
@ -33,8 +34,10 @@ class BaseMapper:
self.db.commit()
return result
async with self.semaphore:
return await asyncio.to_thread(_execute)
return _execute()
#async with self.semaphore:
# return await self.loop.run_in_executor(None, _execute)
async def new(self):
return self.model_class(mapper=self, app=self.app)

View File

@ -146,10 +146,7 @@ class Validator:
return True
def __repr__(self):
return str(self.value)
def __str__(self):
return str(self.value)
return str(self.to_json())
@property
async def is_valid(self):

129
src/snek/system/stats.py Normal file
View File

@ -0,0 +1,129 @@
import asyncio
from aiohttp import web, WSMsgType
from datetime import datetime, timedelta, timezone
from collections import defaultdict
import html
def create_stats_structure():
"""Creates the nested dictionary structure for storing statistics."""
def nested_dd():
return defaultdict(lambda: defaultdict(int))
return defaultdict(nested_dd)
def get_time_keys(dt: datetime):
"""Generates dictionary keys for different time granularities."""
return {
"hour": dt.strftime('%Y-%m-%d-%H'),
"day": dt.strftime('%Y-%m-%d'),
"week": dt.strftime('%Y-%W'), # Week number, Monday is first day
"month": dt.strftime('%Y-%m'),
}
def update_stats_counters(stats_dict: defaultdict, now: datetime):
"""Increments the appropriate time-based counters in a stats dictionary."""
keys = get_time_keys(now)
stats_dict['by_hour'][keys['hour']] += 1
stats_dict['by_day'][keys['day']] += 1
stats_dict['by_week'][keys['week']] += 1
stats_dict['by_month'][keys['month']] += 1
def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str:
"""Generates a responsive SVG bar chart for time-series data."""
if not data:
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
max_val = max(item[1] for item in data) if data else 1
svg_height, svg_width = 250, 600
bar_padding = 5
bar_width = (svg_width - 50) / len(data) - bar_padding
bars = ""
labels = ""
for i, (key, val) in enumerate(data):
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
x = i * (bar_width + bar_padding) + 40
y = svg_height - bar_height - 30
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
return f"""
<h3>{html.escape(title)}</h3>
<div style="border:1px solid #ccc; padding: 10px; border-radius: 5px;">
<svg viewBox="0 0 {svg_width} {svg_height}" style="width:100%; height:auto;">
<g>{bars}</g>
<g>{labels}</g>
<line x1="35" y1="10" x2="35" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
<line x1="35" y1="{svg_height - 30}" x2="{svg_width - 10}" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
<text x="5" y="{svg_height - 30}" font-size="12">0</text>
<text x="5" y="20" font-size="12">{max_val}</text>
</svg>
</div>
"""
@web.middleware
async def middleware(request, handler):
"""Middleware to count all incoming HTTP requests."""
# Avoid counting requests to the stats page itself
if request.path.startswith('/stats.html'):
return await handler(request)
update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc))
return await handler(request)
def update_websocket_stats(app):
update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc))
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
"""This function proxies WebSocket messages AND counts them."""
async for msg in ws_from:
# This is the key part for monitoring WebSockets
update_stats_counters(stats_dict, datetime.now(timezone.utc))
if msg.type == WSMsgType.TEXT:
await ws_to.send_str(msg.data)
elif msg.type == WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
await ws_to.close(code=ws_from.close_code)
break
async def stats_handler(request: web.Request):
"""Handler to display the statistics dashboard."""
stats = request.app['stats']
now = datetime.now(timezone.utc)
# Helper to prepare data for charts
def get_data(source, period, count):
data = []
for i in range(count - 1, -1, -1):
if period == 'hour':
dt = now - timedelta(hours=i)
key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00')
data.append((label, source['by_hour'].get(key, 0)))
elif period == 'day':
dt = now - timedelta(days=i)
key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a')
data.append((label, source['by_day'].get(key, 0)))
return data
http_hourly = get_data(stats['http_requests'], 'hour', 24)
ws_hourly = get_data(stats['ws_messages'], 'hour', 24)
http_daily = get_data(stats['http_requests'], 'day', 7)
ws_daily = get_data(stats['ws_messages'], 'day', 7)
body = f"""
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
<body>
<h2>Application Dashboard</h2>
<h3>Last 24 Hours</h3>
{generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")}
{generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")}
<h3>Last 7 Days</h3>
{generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")}
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
</body></html>
"""
return web.Response(text=body, content_type='text/html')

View File

@ -79,38 +79,44 @@ emoji.EMOJI_DATA[
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
"img",
"video",
"audio",
"source",
"iframe",
"picture",
"span",
]
ALLOWED_ATTRIBUTES = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
"img": ["src", "alt", "title", "width", "height"],
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
"iframe": [
"src",
"width",
"height",
"frameborder",
"allow",
"allowfullscreen",
"title",
"referrerpolicy",
"style",
],
"video": ["src", "controls", "width", "height"],
"audio": ["src", "controls"],
"source": ["src", "type"],
"span": ["class"],
"picture": [],
}
def sanitize_html(value):
soup = BeautifulSoup(value, 'html.parser')
for script in soup.find_all('script'):
script.decompose()
#for iframe in soup.find_all('iframe'):
#iframe.decompose()
for tag in soup.find_all(['object', 'embed']):
tag.decompose()
for tag in soup.find_all():
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']
for attr in event_attributes:
if attr in tag.attrs:
del tag[attr]
for img in soup.find_all('img'):
if 'onerror' in img.attrs:
img.decompose()
return soup.prettify()
def sanitize_html2(value):
return bleach.clean(
value,
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
strip=True,
)
@ -126,8 +132,50 @@ def set_link_target_blank(text):
return str(soup)
SAFE_ATTRIBUTES = {
"href",
"src",
"alt",
"title",
"width",
"height",
"style",
"id",
"class",
"rel",
"type",
"name",
"value",
"placeholder",
"aria-hidden",
"aria-label",
"srcset",
"target",
"rel",
"referrerpolicy",
"controls",
"frameborder",
"allow",
"allowfullscreen",
"referrerpolicy",
}
def whitelist_attributes(html):
return sanitize_html(html)
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all():
if hasattr(tag, "attrs"):
if tag.name in ["script", "form", "input"]:
tag.replace_with("")
continue
attrs = dict(tag.attrs)
for attr in list(attrs):
# Check if attribute is in the safe list or is a data-* attribute
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
del tag.attrs[attr]
return str(soup)
def embed_youtube(text):

View File

@ -30,12 +30,9 @@
<link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/fa640.min.css">
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="/buttons.css">
<link rel="stylesheet" href="/inputs.css">
<link rel="stylesheet" href="/lists.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
<script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea" defer></script>
<script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head>
<body>
<header>
@ -45,7 +42,7 @@
<a class="no-select" href="/drive.html">📂</a>
<a class="no-select" href="/search-user.html">🔍</a>
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
<a class="no-select" href="/forum/index.html">💬</a>
<a class="no-select" href="/threads.html">👥</a>
<a class="no-select" href="/settings/index.html">⚙️</a>
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
<a class="no-select" href="/logout.html">🔒</a>

View File

@ -11,8 +11,6 @@
<meta name="color-scheme" content="dark">
<link rel="stylesheet" href="/sandbox.css" />
<link rel="stylesheet" href="/buttons.css" />
<link rel="stylesheet" href="/inputs.css" />
<title>{% block title %}Snek chat by Molodetz{% endblock %}</title>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>

View File

@ -117,13 +117,6 @@ class SnekForum extends HTMLElement {
}
async loadForums() {
if (window.preloadedForums) {
this.currentView = 'forums';
this.renderForums(window.preloadedForums);
this.updateBreadcrumb();
window.preloadedForums = null; // Clear it after use
return;
}
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
@ -702,11 +695,9 @@ class SnekForum extends HTMLElement {
}
updateBreadcrumb() {
const breadcrumbContainer = document.getElementById('breadcrumb');
if (!breadcrumbContainer) return;
return;
const crumb = [];
crumb.push(`<a href="#" data-action="forums">FORUMS</a>`);
crumb.push(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
@ -717,7 +708,7 @@ class SnekForum extends HTMLElement {
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
}
breadcrumbContainer.innerHTML = crumb.join(' ');
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
}
renderForums(forums) {
@ -914,9 +905,6 @@ class SnekForum extends HTMLElement {
}
customElements.define('snek-forum', SnekForum);
</script>
<script>
window.preloadedForums = {{ forums_json|safe }};
</script>
<snek-forum></snek-forum>
{% endblock %}

View File

@ -63,34 +63,16 @@
}
.btn {
display: inline-block;
padding: 10px 20px;
padding: .75rem 1.5rem;
margin: .5rem;
background: #1a1a1a;
border: 1px solid #333;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
background: #0fa;
color: #111;
font-weight: bold;
border-radius: 4px;
transition: all .2s ease;
transition: background .2s;
cursor: pointer;
text-decoration: none;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
text-decoration: none;
}
.btn-primary {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
.btn-primary:hover {
background: #e04924;
border-color: #e04924;
}
.btn:hover { background: #7ef; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@ -167,7 +149,7 @@
<h1>Snek</h1>
<p>Professional Platform for Developers, Testers &amp; AI Professionals</p>
<a href="/login.html" class="btn">Login</a>
<a href="/register.html" class="btn btn-primary">Register</a>
<a href="/register.html" class="btn">Register</a>
<a href="/about.html" class="about-link">About</a>
</header>
<main class="container">
@ -230,7 +212,7 @@
<section id="signup">
<h2>Start Now</h2>
<p>No email. No activity logs. Register a username and access the platform immediately.</p>
<a href="/register.html" class="btn btn-primary">Sign Up</a>
<a href="/register.html" class="btn">Sign Up</a>
<br>
<a href="/about.html" class="about-link">Learn more about Snek</a>
</section>

View File

@ -1,97 +0,0 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<link rel="manifest" href="/manifest.json" />
<style>
body{
background-color: black;
color: white;
}
</style>
</head>
<body>
<script type="module">
import { Socket } from "./socket.js";
class ChatWindow extends HTMLElement {
constructor() {
super();
this.component = document.createElement("div");
this.message_list = document.createElement("div")
this.component.appendChild(this.message_list);
this.chat_input = document.createElement("div")
this.component.appendChild(this.chat_input);
this.channelUid = null
this.channelUid = this.getAttribute("channel")
this.inputText = document.createElement("textarea")
this.inputText.addEventListener("keyup",(e)=>{
this.rpc.sendMessage(this.channelUid, e.target.value,false)
if(e.key == "Enter" && !e.shiftKey){
this.rpc.sendMessage(this.channelUid, e.target.value,true)
e.target.value = ""
}else{
//this.rpc.sendMessage(this.channelUid, e.target.value, false)
}
})
this.component.appendChild(this.inputText)
this.ws = new Socket();
this.ws.addEventListener("channel-message", this.handleMessage.bind(this))
this.rpc = this.ws.client
this.ws.addEventListener("update_message_text",this.handleMessage.bind(this))
window.chat = this
}
async handleMessage(data,data2) {
if(data2 && data2.event)
data = data.data
console.info(["update-messagettt",data])
console.warn(data.uid)
if(!data.html)
return
let div = this.message_list.querySelector('[data-uid="' + data.uid + '"]');
console.info(div)
if(!div){
let temp = document.createElement("chat-message");
temp.innerHTML = data.html
this.message_list.appendChild(temp)
//this.message_list.replace(div,temp)
//div.innerHTML = data.html
//this.message_list.appendChild(div);
}else{
// alert("HIERR")
let temp = document.createElement("chat-message");
temp.innerHTML = data.html;
div.innerHTML = temp.innerHTML
console.info("REPLACE")
}
}
async connectedCallback() {
await this.rpc.ping(this.channel)
console.info(this.channelUid)
this.messages = await this.rpc.getMessages(this.channelUid, 0, 0);
this.messages.forEach((msg) => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
this.message_list.appendChild(temp.firstChild);
})
this.appendChild(this.component);
}
}
customElements.define("chat-window", ChatWindow);
</script>
<chat-window channel="df3e1259-7d1a-4184-b75c-3befd5bf08e1"></chat-window>
</body>
</html>

View File

@ -1,35 +0,0 @@
{% extends "app.html" %}
{% block sidebar %}
<aside class="sidebar" id="channelSidebar">
<h2>Navigation</h2>
<ul>
<li><a class="no-select" href="/user/{{ profile_user.uid }}.html">Back to Profile</a></li>
</ul>
<h2>Pages</h2>
<ul>
{% for p in all_pages %}
<li>
<a class="no-select" href="/user/{{ profile_user.uid }}/{{ p.slug }}.html"
{% if p.uid == page.uid %}style="font-weight: bold;"{% endif %}>
{{ p.title }}
</a>
</li>
{% else %}
<li>No pages</li>
{% endfor %}
</ul>
</aside>
{% endblock %}
{% block header_text %}<h2 style="color:#fff">{{ page.title }}</h2>{% endblock %}
{% block main %}
<section class="chat-area" style="padding:10px">
{% autoescape false %}
{% markdown %}
{{ page.content }}
{% endmarkdown %}
{% endautoescape %}
</section>
{% endblock main %}

View File

@ -1,26 +0,0 @@
{% extends "app.html" %}
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
{% block sidebar %}{% endblock %}
{% block main %}
<div class="container">
<h2>Repository is Empty</h2>
<p>This repository has been created but contains no branches or commits yet.</p>
<h2>Quick Start</h2>
<p>Create a new repository on the command line:</p>
<pre>git init
git add README.md
git commit -m "Initial commit"
git remote add origin {{ clone_url }}
git push -u origin main</pre>
<p>Push an existing repository:</p>
<pre>git remote add origin {{ clone_url }}
git push -u origin main</pre>
<p><a href="/settings/repositories/index.html">← Back to Repositories</a></p>
</div>
{% endblock %}

View File

@ -1,46 +0,0 @@
{% extends "app.html" %}
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
{% block sidebar %}{% endblock %}
{% block main %}
<div class="container">
<p>
<strong>Branch:</strong>
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}/{{ file_path }}?branch=' + this.value">
{% for branch in branches %}
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
</p>
<p>
<a href="/repository/{{ username }}/{{ repo_name }}?branch={{ current_branch }}">{{ repo_name }}</a>
{% if file_path %}
{% set parts = file_path.split('/') %}
{% set cumulative = '' %}
{% for part in parts %}
{% if cumulative %}
{% set cumulative = cumulative + '/' + part %}
{% else %}
{% set cumulative = part %}
{% endif %}
/ {% if loop.last %}<strong>{{ part }}</strong>{% else %}<a href="/repository/{{ username }}/{{ repo_name }}/{{ cumulative }}?branch={{ current_branch }}">{{ part }}</a>{% endif %}
{% endfor %}
{% endif %}
</p>
<p><strong>{{ file_name }}</strong> ({{ file_size }})</p>
{% if is_binary %}
{% if is_image %}
<img src="{{ image_data }}" alt="{{ file_name }}" style="max-width: 100%;">
{% else %}
<p>Binary file - cannot display content</p>
{% endif %}
{% else %}
<pre>{{ content }}</pre>
{% endif %}
</div>
{% endblock %}

View File

@ -1,59 +0,0 @@
{% extends "app.html" %}
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
{% block sidebar %}{% endblock %}
{% block main %}
<div class="container">
{% if repo.description %}
<p>{{ repo.description }}</p>
{% endif %}
<p>
<strong>Branch:</strong>
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}?branch=' + this.value">
{% for branch in branches %}
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
</p>
<p><strong>Clone URL:</strong></p>
<pre>{{ clone_url }}</pre>
<h2>Recent Commits</h2>
{% if commits %}
<ul>
{% for commit in commits %}
<li>
<code>{{ commit.short_hash }}</code> {{ commit.message }} - {{ commit.author }} ({{ commit.date }})
</li>
{% endfor %}
</ul>
{% else %}
<p>No commits found.</p>
{% endif %}
<h2>Files</h2>
<ul>
{% if items %}
{% for item in items %}
<li>
<a href="/repository/{{ username }}/{{ repo_name }}/{{ item.path }}?branch={{ current_branch }}">
{% if item.is_dir %}📁{% else %}📄{% endif %} {{ item.name }}
</a>
{% if not item.is_dir %}({{ item.size }} bytes){% endif %}
</li>
{% endfor %}
{% else %}
<p>No files found.</p>
{% endif %}
</ul>
{% if readme_content %}
<h2>README</h2>
<div>{{ readme_content|safe }}</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,56 +0,0 @@
{% extends "app.html" %}
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
{% block sidebar %}{% endblock %}
{% block main %}
<div class="container">
<p>
<strong>Branch:</strong>
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}/{{ rel_path }}?branch=' + this.value">
{% for branch in branches %}
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
</p>
<p>
<a href="/repository/{{ username }}/{{ repo_name }}?branch={{ current_branch }}">{{ repo_name }}</a>
{% if rel_path %}
{% set parts = rel_path.split('/') %}
{% set cumulative = '' %}
{% for part in parts %}
{% if cumulative %}
{% set cumulative = cumulative + '/' + part %}
{% else %}
{% set cumulative = part %}
{% endif %}
/ <a href="/repository/{{ username }}/{{ repo_name }}/{{ cumulative }}?branch={{ current_branch }}">{{ part }}</a>
{% endfor %}
{% endif %}
</p>
<ul>
{% if parent_path != None %}
<li>
<a href="/repository/{{ username }}/{{ repo_name }}{% if parent_path %}/{{ parent_path }}{% endif %}?branch={{ current_branch }}">
⬆️ ..
</a>
</li>
{% endif %}
{% if items %}
{% for item in items %}
<li>
<a href="/repository/{{ username }}/{{ repo_name }}/{{ item.path }}?branch={{ current_branch }}">
{% if item.is_dir %}📁{% else %}📄{% endif %} {{ item.name }}
</a>
{% if not item.is_dir %}({{ item.size }} bytes){% endif %}
</li>
{% endfor %}
{% else %}
<p>No files found.</p>
{% endif %}
</ul>
</div>
{% endblock %}

View File

@ -12,7 +12,7 @@ function showTerm(options){
class StarField {
constructor({ count = 50, container = document.body } = {}) {
constructor({ count = 200, container = document.body } = {}) {
this.container = container;
this.starCount = count;
this.stars = [];
@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
const starField = new StarField({starCount: 50});
const starField = new StarField({starCount: 200});
app.starField = starField;
class DemoSequence {

View File

@ -1,88 +1,28 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>
form {
padding: 2rem;
border-radius: 10px;
div {
padding: 10px;
padding-bottom: 15px
<style>
form {
padding: 2rem;
border-radius: 10px;
div {
padding: 10px;
padding-bottom: 15px
}
}
}
label {
font-family: 'Courier New', monospace;
font-weight: bold;
display: flex;
align-items: center;
gap: 0.5rem;
color: #aaa;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
input[type="text"], input[type="password"], textarea {
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[type="text"]:focus, input[type="password"]:focus, textarea:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #f05a28;
cursor: pointer;
}
button, a.cancel {
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;
}
button:hover, a.cancel:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
button[type="submit"] {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
button[type="submit"]:hover {
background: #e04924;
border-color: #e04924;
}
.cancel {
background: #2a2a2a;
border-color: #444;
}
.cancel:hover {
background: #3a3a3a;
border-color: #555;
}
@media (max-width: 600px) {
.container { max-width: 98vw; }
form { padding: 1rem; }
}
</style>
label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}
button {
background: #0d6efd; color: #fff;
border: none; border-radius: 5px; padding: 0.6rem 1rem;
cursor: pointer;
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
}
.cancel {
background: #6c757d;
}
@media (max-width: 600px) {
.container { max-width: 98vw; }
form { padding: 1rem; }
}
</style>

View File

@ -49,71 +49,16 @@
margin-bottom: 1rem;
}
button, a.button {
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;
}
button:hover, a.button:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
.button.delete {
background: #1a1a1a;
border-color: #8b0000;
color: #ff6b6b;
}
.button.delete:hover {
background: #2a1515;
border-color: #b00;
}
.button.edit {
background: #2a2a2a;
border-color: #444;
}
.button.edit:hover {
background: #3a3a3a;
border-color: #555;
}
.button.clone {
background: #2a2a2a;
border-color: #444;
}
.button.clone:hover {
background: #3a3a3a;
border-color: #555;
}
.button.browse {
background: #2a2a2a;
border-color: #444;
color: #e6e6e6;
}
.button.browse:hover {
background: #3a3a3a;
border-color: #555;
}
.button.create {
background: #f05a28;
border-color: #f05a28;
color: #fff;
margin-left: 0;
}
.button.create:hover {
background: #e04924;
border-color: #e04924;
background: #198754; color: #fff; border: none; border-radius: 5px;
padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;
transition: background 0.2s;
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
}
.button.delete { background: #dc3545; }
.button.edit { background: #0d6efd; }
.button.clone { background: #6c757d; }
.button.browse { background: #ffc107; color: #212529; }
.button.create { background: #20c997; margin-left: 0.5rem; }
</style>
</head>
<body>

View File

@ -8,7 +8,9 @@
{% block head %}
<link href="https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs/editor/editor.main.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs/loader.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/monaco-editor/0.20.0/min/vs/loader.min.js"></script>
{% endblock %}
{% block logo %}

View File

@ -3,55 +3,35 @@
{% block header_text %}<h2 style="color:#fff">Profile</h2>{% endblock %}
{% block main %}
<section style="padding: 20px;">
<section>
<form method="post">
<div class="form-group">
<label class="form-label">Nickname</label>
<input type="text" name="nick" placeholder="Your nickname" value="{{ user.nick.value }}" class="input" />
</div>
<h2>Nickname</h2>
<input type="text" name="nick" placeholder="Your nickname" value="{{ user.nick.value }}" />
<h2>Description</h2>
<textarea name="profile" id="profile">{{profile}}</textarea>
<input type="submit" name="action" value="Save" />
</form>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="profile" id="profile" class="textarea">{{profile}}</textarea>
</div>
<button type="submit" name="action" value="Save" class="btn btn-primary">Save</button>
</form>
</section>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
const easyMDE = new EasyMDE({element:document.getElementById("profile")});
document.querySelector('form').addEventListener('submit', function(e) {
easyMDE.codemirror.save();
});
</script>
</script>
<style>
.EasyMDEContainer {
background: #0f0f0f;
color: #e6e6e6;
}
.EasyMDEContainer .CodeMirror {
background: #0f0f0f;
color: #e6e6e6;
border: 1px solid #333;
}
.EasyMDEContainer .editor-toolbar {
background: #1a1a1a;
border: 1px solid #333;
border-bottom: none;
}
.EasyMDEContainer .editor-toolbar button {
color: #e6e6e6 !important;
}
.EasyMDEContainer .editor-toolbar button:hover {
background: #2a2a2a;
}
.CodeMirror-cursor {
border-left-color: #e6e6e6 !important;
}
.editor-statusbar {
color: #888 !important;
}
</style>
filter: invert(1) !important;
}
</style>
{% endblock %}

View File

@ -1,127 +0,0 @@
{% extends "settings/index.html" %}
{% block header_text %}<h2 style="color:#fff">Create Profile Page</h2>{% endblock %}
{% block main %}
<section style="padding: 20px; height: 100%; overflow: auto; display: flex; flex-direction: column;">
<div style="margin-bottom: 16px;">
<a href="/settings/profile_pages/index.html" class="btn btn-sm">
Back to Pages
</a>
</div>
{% if error %}
<div style="background: #2a1515; border: 1px solid #8b0000; color: #ff6b6b; padding: 10px; margin-bottom: 16px; border-radius: 4px;">
{{ error }}
</div>
{% endif %}
<form method="post" style="flex: 1; display: flex; flex-direction: column;">
<div class="form-group">
<label for="title" class="form-label">
Page Title <span style="color: #ff6b6b;">*</span>
</label>
<input
type="text"
id="title"
name="title"
value="{{ title or '' }}"
required
placeholder="e.g., About Me, Projects, Blog"
class="input"
/>
</div>
<div class="form-group">
<label for="is_published" class="checkbox-wrapper">
<input
type="checkbox"
id="is_published"
name="is_published"
checked
class="checkbox"
/>
<span style="color: #e6e6e6;">Publish immediately</span>
</label>
</div>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0;" class="form-group">
<label for="content" class="form-label">
Content (Markdown)
</label>
<textarea id="content" name="content" class="textarea" style="flex: 1;">{{ content or '' }}</textarea>
</div>
<div style="margin-top: 16px; display: flex; gap: 10px;">
<button type="submit" class="btn btn-primary">
Create Page
</button>
<a href="/settings/profile_pages/index.html" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</section>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
const textarea = document.getElementById("content");
const container = textarea.parentElement;
const containerHeight = container.offsetHeight;
const editorHeight = Math.max(200, containerHeight - 50);
const easyMDE = new EasyMDE({
element: textarea,
minHeight: editorHeight + "px",
maxHeight: editorHeight + "px",
autosave: {
enabled: true,
uniqueId: "new_profile_page",
delay: 1000,
},
spellChecker: false,
status: ["lines", "words"],
placeholder: "Write your content here using Markdown...",
});
document.querySelector('form').addEventListener('submit', function(e) {
easyMDE.codemirror.save();
});
</script>
<style>
.EasyMDEContainer {
background: #0f0f0f;
color: #e6e6e6;
height: 100%;
}
.EasyMDEContainer .CodeMirror {
background: #0f0f0f;
color: #e6e6e6;
border: 1px solid #333;
}
.EasyMDEContainer .editor-toolbar {
background: #1a1a1a;
border: 1px solid #333;
border-bottom: none;
}
.EasyMDEContainer .editor-toolbar button {
color: #e6e6e6 !important;
}
.EasyMDEContainer .editor-toolbar button:hover {
background: #2a2a2a;
}
.CodeMirror-cursor {
border-left-color: #e6e6e6 !important;
}
.editor-statusbar {
color: #888 !important;
}
</style>
{% endblock %}

View File

@ -1,133 +0,0 @@
{% extends "settings/index.html" %}
{% block header_text %}<h2 style="color:#fff">Edit Profile Page</h2>{% endblock %}
{% block main %}
<section style="padding: 20px; height: 100%; overflow: auto; display: flex; flex-direction: column;">
<div style="margin-bottom: 16px;">
<a href="/settings/profile_pages/index.html" class="btn btn-sm">
Back to Pages
</a>
</div>
{% if error %}
<div style="background: #2a1515; border: 1px solid #8b0000; color: #ff6b6b; padding: 10px; margin-bottom: 16px; border-radius: 4px;">
{{ error }}
</div>
{% endif %}
<form method="post" style="flex: 1; display: flex; flex-direction: column;">
<div class="form-group">
<label for="title" class="form-label">
Page Title <span style="color: #ff6b6b;">*</span>
</label>
<input
type="text"
id="title"
name="title"
value="{{ page.title }}"
required
placeholder="e.g., About Me, Projects, Blog"
class="input"
/>
<p style="color: #888; font-size: 0.85em; margin-top: 6px;">
Slug: {{ page.slug }}
</p>
</div>
<div class="form-group">
<label for="is_published" class="checkbox-wrapper">
<input
type="checkbox"
id="is_published"
name="is_published"
{% if page.is_published %}checked{% endif %}
class="checkbox"
/>
<span style="color: #e6e6e6;">Publish this page</span>
</label>
</div>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0;" class="form-group">
<label for="content" class="form-label">
Content (Markdown)
</label>
<textarea id="content" name="content" class="textarea" style="flex: 1;">{{ page.content }}</textarea>
</div>
<div style="margin-top: 16px; display: flex; gap: 10px;">
<button type="submit" class="btn btn-primary">
Save Changes
</button>
<a href="/user/{{ page.user_uid }}/{{ page.slug }}.html" class="btn btn-secondary">
Preview
</a>
<a href="/settings/profile_pages/index.html" class="btn">
Cancel
</a>
</div>
</form>
</section>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
const textarea = document.getElementById("content");
const container = textarea.parentElement;
const containerHeight = container.offsetHeight;
const editorHeight = Math.max(200, containerHeight - 50);
const easyMDE = new EasyMDE({
element: textarea,
minHeight: editorHeight + "px",
maxHeight: editorHeight + "px",
autosave: {
enabled: true,
uniqueId: "edit_profile_page_{{ page.uid }}",
delay: 1000,
},
spellChecker: false,
status: ["lines", "words"],
placeholder: "Write your content here using Markdown...",
});
document.querySelector('form').addEventListener('submit', function(e) {
easyMDE.codemirror.save();
});
</script>
<style>
.EasyMDEContainer {
background: #0f0f0f;
color: #e6e6e6;
height: 100%;
}
.EasyMDEContainer .CodeMirror {
background: #0f0f0f;
color: #e6e6e6;
border: 1px solid #333;
}
.EasyMDEContainer .editor-toolbar {
background: #1a1a1a;
border: 1px solid #333;
border-bottom: none;
}
.EasyMDEContainer .editor-toolbar button {
color: #e6e6e6 !important;
}
.EasyMDEContainer .editor-toolbar button:hover {
background: #2a2a2a;
}
.CodeMirror-cursor {
border-left-color: #e6e6e6 !important;
}
.editor-statusbar {
color: #888 !important;
}
</style>
{% endblock %}

View File

@ -1,55 +0,0 @@
{% extends "settings/index.html" %}
{% block header_text %}<h2 style="color:#fff">Profile Pages</h2>{% endblock %}
{% block main %}
<section style="padding: 20px;">
<div class="settings-topbar">
<h2>Your Profile Pages</h2>
<a href="/settings/profile_pages/create.html" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> New Page
</a>
</div>
{% if pages %}
<div class="settings-list">
{% for page in pages %}
<div class="settings-list-item">
<div class="settings-list-info">
<div class="settings-list-title">
<i class="fa-solid fa-file-lines"></i>
{{ page.title }}
{% if not page.is_published %}
<span class="settings-list-badge">(Draft)</span>
{% endif %}
</div>
<div class="settings-list-meta">
<span>Slug: <code>{{ page.slug }}</code></span>
</div>
</div>
<div class="settings-list-actions">
<a href="/user/{{ user.uid.value }}/{{ page.slug }}.html" class="btn btn-sm">
<i class="fa-solid fa-eye"></i> View
</a>
<a href="/settings/profile_pages/{{ page.uid }}/edit.html" class="btn btn-sm btn-secondary">
<i class="fa-solid fa-edit"></i> Edit
</a>
<form method="post" action="/settings/profile_pages/{{ page.uid }}/delete.html" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this page?');">
<button type="submit" class="btn btn-sm btn-danger">
<i class="fa-solid fa-trash"></i> Delete
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="settings-empty">
<p>You haven't created any profile pages yet.</p>
<a href="/settings/profile_pages/create.html" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Create Your First Page
</a>
</div>
{% endif %}
</section>
{% endblock %}

View File

@ -10,18 +10,14 @@
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
<input type="text" id="name" name="name" required placeholder="Repository name">
</div>
<div>
<label for="description"><i class="fa-solid fa-align-left"></i> Description</label>
<input type="text" id="description" name="description" placeholder="Repository description (optional)">
</div>
<div>
<label>
<input type="checkbox" name="is_private" value="1">
<i class="fa-solid fa-lock"></i> Private
</label>
</div>
<button type="submit"><i class="fa-solid fa-plus"></i> Create</button>
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
</form>
</div>

View File

@ -1,88 +1,28 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>
form {
padding: 2rem;
border-radius: 10px;
div {
padding: 10px;
padding-bottom: 15px
<style>
form {
padding: 2rem;
border-radius: 10px;
div {
padding: 10px;
padding-bottom: 15px
}
}
}
label {
font-family: 'Courier New', monospace;
font-weight: bold;
display: flex;
align-items: center;
gap: 0.5rem;
color: #aaa;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
input[type="text"], input[type="password"], textarea {
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[type="text"]:focus, input[type="password"]:focus, textarea:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #f05a28;
cursor: pointer;
}
button, a.cancel {
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;
}
button:hover, a.cancel:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
button[type="submit"] {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
button[type="submit"]:hover {
background: #e04924;
border-color: #e04924;
}
.cancel {
background: #2a2a2a;
border-color: #444;
}
.cancel:hover {
background: #3a3a3a;
border-color: #555;
}
@media (max-width: 600px) {
.container { max-width: 98vw; }
form { padding: 1rem; }
}
</style>
label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}
button {
background: #0d6efd; color: #fff;
border: none; border-radius: 5px; padding: 0.6rem 1rem;
cursor: pointer;
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
}
.cancel {
background: #6c757d;
}
@media (max-width: 600px) {
.container { max-width: 98vw; }
form { padding: 1rem; }
}
</style>

View File

@ -1,59 +1,106 @@
{% extends 'settings/index.html' %}
{% block header_text %}<h2 style="color:#fff">Repositories</h2>{% endblock %}
{% block header_text %}<h1><i class="fa-solid fa-database"></i> Repositories</h1>{% endblock %}
{% block main %}
<section style="padding: 20px;">
<div class="settings-topbar">
<h2>Your Repositories</h2>
<a href="/settings/repositories/create.html" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> New Repository
</a>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Repositories - List</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>
.actions {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.repo-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.repo-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-radius: 8px;
flex-wrap: wrap;
}
.repo-info {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 220px;
}
.repo-name {
font-size: 1.1rem;
font-weight: 600;
}
@media (max-width: 600px) {
.repo-row { flex-direction: column; align-items: stretch; }
.actions { justify-content: flex-start; }
}
.topbar {
display: flex;
/* justify-content: flex-end;*/
margin-bottom: 1rem;
}
button, a.button {
background: #198754; color: #fff; border: none; border-radius: 5px;
padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;
transition: background 0.2s;
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
}
.button.delete { background: #dc3545; }
.button.edit { background: #0d6efd; }
.button.clone { background: #6c757d; }
.button.browse { background: #ffc107; color: #212529; }
.button.create { background: #20c997; margin-left: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<a class="button create" href="/settings/repositories/create.html">
<i class="fa-solid fa-plus"></i> New Repository
</a>
</div>
{% if repositories %}
<div class="settings-list">
{% for repo in repositories %}
<div class="settings-list-item">
<div class="settings-list-info">
<div class="settings-list-title">
<i class="fa-solid fa-book"></i>
{{ repo.name }}
</div>
{% if repo.description %}
<div class="settings-list-meta">{{ repo.description }}</div>
{% endif %}
<div class="settings-list-meta">
<span class="settings-list-badge">
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
{% if repo.is_private %}Private{% else %}Public{% endif %}
</span>
</div>
</div>
<div class="settings-list-actions">
<a class="btn btn-sm" href="/repository/{{ user.username.value }}/{{ repo.name }}" target="_blank">
<i class="fa-solid fa-folder-open"></i> Browse
</a>
<button class="btn btn-sm" onclick="navigator.clipboard.writeText(window.location.protocol + '//' + window.location.host + '/git/{{ user.username.value }}/{{ repo.name }}.git'); alert('Clone URL copied to clipboard!')">
<i class="fa-solid fa-code-branch"></i> Clone URL
</button>
<a class="btn btn-sm btn-secondary" href="/settings/repositories/repository/{{ repo.name }}/update.html">
<i class="fa-solid fa-pen"></i> Edit
</a>
<a class="btn btn-sm btn-danger" href="/settings/repositories/repository/{{ repo.name }}/delete.html">
<i class="fa-solid fa-trash"></i> Delete
</a>
</div>
<section class="repo-list">
<!-- Example repository entries; replace with your templating/iteration -->
{% for repo in repositories %}
<div class="repo-row">
<div class="repo-info">
<span class="repo-name"><i class="fa-solid fa-book"></i> {{ repo.name }}</span>
<span title="Public">
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
{% if repo.is_private %}Private{% else %}Public{% endif %}
</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="settings-empty">
<p>You haven't created any repositories yet.</p>
<a href="/settings/repositories/create.html" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Create Your First Repository
</a>
</div>
{% endif %}
</section>
<div class="actions">
<a class="button browse" href="/repository/{{ user.username.value }}/{{ repo.name }}" target="_blank">
<i class="fa-solid fa-folder-open"></i> Browse
</a>
<a class="button clone" href="/git/{{ user.uid.value }}/{{ repo.name.value }}">
<i class="fa-solid fa-code-branch"></i> Clone
</a>
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
<i class="fa-solid fa-pen"></i> Edit
</a>
<a class="button delete" href="/settings/repositories/repository/{{ repo.name }}/delete.html">
<i class="fa-solid fa-trash"></i> Delete
</a>
</div>
</div>
{% endfor %}
<!-- ... -->
</section>
</div>
</body>
</html>
{% endblock %}

View File

@ -6,15 +6,12 @@
{% include "settings/repositories/form.html" %}
<div class="container">
<form method="post">
<!-- Assume hidden id for backend use -->
<input type="hidden" name="id" value="{{ repository.id }}">
<div>
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
<input type="text" id="name" name="name" value="{{ repository.name }}" readonly>
</div>
<div>
<label for="description"><i class="fa-solid fa-align-left"></i> Description</label>
<input type="text" id="description" name="description" value="{{ repository.description }}" placeholder="Repository description (optional)">
</div>
<div>
<label>
<input type="checkbox" name="is_private" value="1" {% if repository.is_private %}checked{% endif %}>
@ -22,7 +19,7 @@
</label>
</div>
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
</form>
</div>
{% endblock %}

View File

@ -1,8 +1,9 @@
<aside class="sidebar settings-sidebar" id="channelSidebar">
<h2>Settings</h2>
<aside class="sidebar" id="channelSidebar">
<h2>You</h2>
<ul>
<li><a class="no-select" href="/settings/profile.html">Profile</a></li>
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
</ul>
</aside>
</aside>

View File

@ -12,15 +12,7 @@
<li><a class="no-select" href="/user/{{ user.uid }}.html">Profile</a></li>
<li><a class="no-select" href="/channel/{{ user.uid }}.html">DM</a></li>
</ul>
{% if profile_pages %}
<h2>Pages</h2>
<ul>
{% for page in profile_pages %}
<li><a class="no-select" href="/user/{{ user.uid }}/{{ page.slug }}.html">{{ page.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
<h2>Gists</h2>
<h2>Gists</h2>
<ul>
<li>No gists</li>
</ul>

View File

@ -1,6 +1,6 @@
{% extends "app.html" %}
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
{% block main %}
{% include "channel.html" %}
@ -8,14 +8,11 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<div id="terminal" class="hidden"></div>
<button id="jump-to-unread-btn" style="display: none; position: absolute; top: 10px; right: 10px; z-index: 1000; padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
Jump to First Unread
</button>
<message-list class="chat-messages">
{% if not messages %}
<div>
<h1>Welcome to your new channel!</h1>
<p>This is the start of something great. Use the commands below to get started:</p>
@ -40,9 +37,10 @@
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
<script type="module">
import {app} from "/app.js";
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
// --- Cache selectors ---
// --- Cache selectors ---
const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
@ -54,7 +52,7 @@ chatInputField.autoCompletions = {
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp,
"/container": async() =>{
"/container": async() =>{
containerDialog.openWithStatus()
}
};
@ -101,61 +99,21 @@ setInterval(() => requestIdleCallback(updateTimes), 30000);
// --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea;
function uploadDataTransfer(dt) {
if (dt.items.length > 0) {
const uploadButton = chatInputField.fileUploadGrid;
for (const item of dt.items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
uploadButton.uploadsStarted++
uploadButton.createTile(file)
} else {
console.error("Failed to get file from DataTransferItem");
}
}
}
}
}
textBox.addEventListener("paste", async (e) => {
try {
console.log("Pasted data:", e.clipboardData);
if (e.clipboardData.types.every(v => v.startsWith("text/"))) {
const codeType = e.clipboardData.types.find(t => !t.startsWith('text/plain') && !t.startsWith('text/html') && !t.startsWith('text/rtf'));
const probablyCode = codeType ||e.clipboardData.types.some(t => !t.startsWith('text/plain'));
for (const item of e.clipboardData.items) {
if (item.kind === "string" && item.type === "text/plain") {
e.preventDefault();
item.getAsString(text => {
const value = chatInputField.value;
if (probablyCode) {
let code = text;
const minIndentDepth = code.split('\n').reduce((acc, line) => {
if (!line.trim()) return acc;
const match = line.match(/^(\s*)/);
return match ? Math.min(acc, match[1].length) : acc;
}, 9000);
code = code.split('\n').map(line => line.slice(minIndentDepth)).join('\n')
text = `\`\`\`${codeType?.split('/')?.[1] ?? ''}\n${code}\n\`\`\`\n`;
}
const area = chatInputField.textarea
if(area){
const start = area.selectionStart
if ("\n" !== value[start - 1]) {
text = `\n${text}`;
}
document.execCommand('insertText', false, text);
}
});
break;
}
const clipboardItems = await navigator.clipboard.read();
const dt = new DataTransfer();
for (const item of clipboardItems) {
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
const blob = await item.getType(type);
dt.items.add(new File([blob], "image.png", { type }));
}
} else {
uploadDataTransfer(e.clipboardData);
}
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
@ -163,7 +121,13 @@ textBox.addEventListener("paste", async (e) => {
});
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
uploadDataTransfer(e.dataTransfer);
const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
});
chatArea.addEventListener("dragover", e => {
e.preventDefault();
@ -216,7 +180,7 @@ app.ws.addEventListener("starfield.render_word", (data) => {
// --- Channel message event ---
app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) {
@ -257,7 +221,7 @@ document.addEventListener('keydown', function(event) {
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) {
gPressCount = 0;
gPressCount = 0;
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra();
}
@ -303,76 +267,7 @@ function isScrolledPastHalf() {
// --- Initial layout update ---
updateLayout(true);
// --- Jump to unread functionality ---
const jumpToUnreadBtn = document.getElementById('jump-to-unread-btn');
let firstUnreadMessageUid = null;
async function checkForUnreadMessages() {
try {
const uid = await app.rpc.getFirstUnreadMessageUid(channelUid);
if (uid) {
firstUnreadMessageUid = uid;
const messageElement = messagesContainer.querySelector(`[data-uid="${uid}"]`);
if (messageElement && !messagesContainer.isElementVisible(messageElement)) {
jumpToUnreadBtn.style.display = 'block';
} else {
jumpToUnreadBtn.style.display = 'none';
}
} else {
jumpToUnreadBtn.style.display = 'none';
}
} catch (error) {
console.error('Error checking for unread messages:', error);
}
}
async function jumpToUnread() {
if (!firstUnreadMessageUid) {
await checkForUnreadMessages();
}
if (firstUnreadMessageUid) {
let messageElement = messagesContainer.querySelector(`[data-uid="${firstUnreadMessageUid}"]`);
if (!messageElement) {
const messages = await app.rpc.getMessages(channelUid, 0, null);
const targetMessage = messages.find(m => m.uid === firstUnreadMessageUid);
if (targetMessage) {
const temp = document.createElement("div");
temp.innerHTML = targetMessage.html;
const newMessageElement = temp.firstChild;
messagesContainer.endOfMessages.after(newMessageElement);
messagesContainer.messageMap.set(targetMessage.uid, newMessageElement);
messagesContainer._observer.observe(newMessageElement);
messageElement = newMessageElement;
}
}
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.style.animation = 'highlight-fade 2s';
setTimeout(() => {
messageElement.style.animation = '';
}, 2000);
jumpToUnreadBtn.style.display = 'none';
}
}
}
jumpToUnreadBtn.addEventListener('click', jumpToUnread);
checkForUnreadMessages();
const style = document.createElement('style');
style.textContent = `
@keyframes highlight-fade {
0% { background-color: rgba(255, 255, 0, 0.3); }
100% { background-color: transparent; }
}
`;
document.head.appendChild(style);
</script>
{% endblock %}

View File

@ -71,10 +71,7 @@ class AvatarView(BaseView):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
avatar = await self.app.get(uid)
if not avatar:
avatar = multiavatar.multiavatar(uid, True, None)
await self.app.set(uid, avatar)
avatar = multiavatar.multiavatar(uid, True, None)
response = web.Response(text=avatar, content_type="image/svg+xml")
response.headers["Cache-Control"] = f"public, max-age={1337*42}"
return response

View File

@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
class ChannelAttachmentView(BaseView):
login_required=False
login_required=True
async def get(self):
relative_path = self.request.match_info.get("relative_url")

View File

@ -17,27 +17,57 @@ class ForumIndexView(BaseView):
async def get(self):
if self.login_required and not self.session.get("logged_in"):
return web.HTTPFound("/")
forums = []
async for forum in self.services.forum.get_active_forums():
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"],
"last_post_at": forum["last_post_at"],
"last_thread_uid": forum["last_thread_uid"]
})
channel = await self.services.channel.get(
uid=self.request.match_info.get("channel")
)
if not channel:
user = await self.services.user.get(
uid=self.request.match_info.get("channel")
)
if user:
channel = await self.services.channel.get_dm(
self.session.get("uid"), user["uid"]
)
if channel:
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
if not channel:
return web.HTTPNotFound()
channel_member = await self.app.services.channel_member.get(
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPNotFound()
channel_member["new_count"] = 0
await self.app.services.channel_member.save(channel_member)
user = await self.services.user.get(uid=self.session.get("uid"))
messages = [
await self.app.services.channel_message.to_extended_dict(message)
for message in await self.app.services.channel_message.offset(
channel["uid"]
)
]
for message in messages:
await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"]
)
name = await channel_member.get_name()
return await self.render_template(
"forum.html",
{
"forums_json": json.dumps(forums),
"user": await self.services.user.get(self.session.get("uid"))
},
{"name": name, "channel": channel, "user": user, "messages": messages},
)
@ -50,9 +80,11 @@ class ForumView(BaseView):
login_required = True
async def get_forums(self):
request = self
self = request.app
"""GET /forum/api/forums - Get all active forums"""
forums = []
async for forum in self.app.services.forum.get_active_forums():
async for forum in self.services.forum.get_active_forums():
forums.append({
"uid": forum["uid"],
"name": forum["name"],
@ -67,25 +99,28 @@ class ForumView(BaseView):
return web.json_response({"forums": forums})
async def get_forum(self):
request = self
self = request.app
setattr(self, "request", request)
"""GET /forum/api/forums/:slug - Get forum by slug"""
slug = self.match_info["slug"]
forum = await self.app.services.forum.get(slug=slug, is_active=True)
slug = self.request.match_info["slug"]
forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
# Get threads
threads = []
page = int(self.query.get("page", 1))
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
async for thread in forum.get_threads(limit=limit, offset=offset):
# Get author info
author = await self.app.services.user.get(uid=thread["created_by_uid"])
author = await self.services.user.get(uid=thread["created_by_uid"])
last_post_author = None
if thread["last_post_by_uid"]:
last_post_author = await self.app.services.user.get(uid=thread["last_post_by_uid"])
last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
threads.append({
"uid": thread["uid"],
@ -127,17 +162,21 @@ class ForumView(BaseView):
})
async def create_thread(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/forums/:slug/threads - Create new thread"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
slug = self.match_info["slug"]
forum = await self.app.services.forum.get(slug=slug, is_active=True)
slug = self.request.match_info["slug"]
forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
data = await self.json()
data = await self.request.json()
title = data.get("title", "").strip()
content = data.get("content", "").strip()
@ -145,11 +184,11 @@ class ForumView(BaseView):
return web.json_response({"error": "Title and content required"}, status=400)
try:
thread, post = await self.app.services.thread.create_thread(
thread, post = await self.services.thread.create_thread(
forum_uid=forum["uid"],
title=title,
content=content,
created_by_uid=self.session["uid"]
created_by_uid=self.request.session["uid"]
)
return web.json_response({
@ -163,9 +202,13 @@ class ForumView(BaseView):
return web.json_response({"error": str(e)}, status=400)
async def get_thread(self):
request = self
self = request.app
setattr(self, "request", request)
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
thread_slug = self.match_info["thread_slug"]
thread = await self.app.services.thread.get(slug=thread_slug)
thread_slug = self.request.match_info["thread_slug"]
thread = await self.services.thread.get(slug=thread_slug)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
@ -174,15 +217,15 @@ class ForumView(BaseView):
await thread.increment_view_count()
# Get forum
forum = await self.app.services.forum.get(uid=thread["forum_uid"])
forum = await self.services.forum.get(uid=thread["forum_uid"])
# Get posts
posts = []
page = int(self.query.get("page", 1))
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
current_user_uid = self.session.get("uid")
current_user_uid = self.request.session.get("uid")
async for post in thread.get_posts(limit=limit, offset=offset):
author = await post.get_author()
@ -207,7 +250,7 @@ class ForumView(BaseView):
})
# Get thread author
thread_author = await self.app.services.user.get(uid=thread["created_by_uid"])
thread_author = await self.services.user.get(uid=thread["created_by_uid"])
return web.json_response({
"thread": {
@ -237,28 +280,32 @@ class ForumView(BaseView):
})
async def create_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/posts - Create new post"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.match_info["thread_uid"]
thread = await self.app.services.thread.get(uid=thread_uid)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
data = await self.json()
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
try:
post = await self.app.services.post.create_post(
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=thread["forum_uid"],
content=content,
created_by_uid=self.session["uid"]
created_by_uid=self.request.session["uid"]
)
author = await post.get_author()
@ -282,21 +329,25 @@ class ForumView(BaseView):
return web.json_response({"error": str(e)}, status=400)
async def edit_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""PUT /forum/api/posts/:post_uid - Edit post"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.match_info["post_uid"]
data = await self.json()
post_uid = self.request.match_info["post_uid"]
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
post = await self.app.services.post.edit_post(
post = await self.services.post.edit_post(
post_uid=post_uid,
content=content,
user_uid=self.session["uid"]
user_uid=self.request.session["uid"]
)
if not post:
@ -311,15 +362,19 @@ class ForumView(BaseView):
})
async def delete_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""DELETE /forum/api/posts/:post_uid - Delete post"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.match_info["post_uid"]
post_uid = self.request.match_info["post_uid"]
success = await self.app.services.post.delete_post(
success = await self.services.post.delete_post(
post_uid=post_uid,
user_uid=self.session["uid"]
user_uid=self.request.session["uid"]
)
if not success:
@ -328,22 +383,26 @@ class ForumView(BaseView):
return web.json_response({"success": True})
async def toggle_like(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/posts/:post_uid/like - Toggle post like"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.match_info["post_uid"]
post_uid = self.request.match_info["post_uid"]
is_liked = await self.app.services.post_like.toggle_like(
is_liked = await self.services.post_like.toggle_like(
post_uid=post_uid,
user_uid=self.session["uid"]
user_uid=self.request.session["uid"]
)
if is_liked is None:
return web.json_response({"error": "Failed to toggle like"}, status=400)
# Get updated post
post = await self.app.services.post.get(uid=post_uid)
post = await self.services.post.get(uid=post_uid)
return web.json_response({
"is_liked": is_liked,
@ -351,15 +410,19 @@ class ForumView(BaseView):
})
async def toggle_pin(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.match_info["thread_uid"]
thread_uid = self.request.match_info["thread_uid"]
thread = await self.app.services.thread.toggle_pin(
thread = await self.services.thread.toggle_pin(
thread_uid=thread_uid,
user_uid=self.session["uid"]
user_uid=self.request.session["uid"]
)
if not thread:
@ -368,15 +431,19 @@ class ForumView(BaseView):
return web.json_response({"is_pinned": thread["is_pinned"]})
async def toggle_lock(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
if not self.session.get("logged_in"):
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.match_info["thread_uid"]
thread_uid = self.request.match_info["thread_uid"]
thread = await self.app.services.thread.toggle_lock(
thread = await self.services.thread.toggle_lock(
thread_uid=thread_uid,
user_uid=self.session["uid"]
user_uid=self.request.session["uid"]
)
if not thread:

View File

@ -1,10 +0,0 @@
import logging
from snek.system.view import BaseView
logger = logging.getLogger(__name__)
class GitIntegrationDocsView(BaseView):
async def get(self):
return await self.render_template("GIT_INTEGRATION.md")

View File

@ -1,8 +0,0 @@
from snek.system.view import BaseView
class NewView(BaseView):
login_required = True
async def get(self):
return await self.render_template("new.html")

View File

@ -1,45 +0,0 @@
import logging
from aiohttp import web
from snek.system.view import BaseView
logger = logging.getLogger(__name__)
class ProfilePageView(BaseView):
login_required = False
async def get(self):
user_uid = self.request.match_info.get("user_uid")
slug = self.request.match_info.get("slug")
user = await self.services.user.get(uid=user_uid, deleted_at=None)
if not user:
user = await self.services.user.get(username=user_uid, deleted_at=None)
if not user:
raise web.HTTPNotFound()
page = await self.services.profile_page.get(
user_uid=user["uid"],
slug=slug,
deleted_at=None
)
if not page:
raise web.HTTPNotFound()
if not page["is_published"]:
if not self.session.get("uid") or self.session.get("uid") != user["uid"]:
raise web.HTTPNotFound()
all_pages = await self.services.profile_page.get_user_pages(
user["uid"],
include_unpublished=False
)
return await self.render_template(
"profile_page.html",
{
"page": page,
"profile_user": user,
"all_pages": all_pages
}
)

View File

@ -1,33 +1,189 @@
import asyncio
import base64
import mimetypes
import os
import urllib.parse
from datetime import datetime
from pathlib import Path
import git
import humanize
from aiohttp import web
from snek.system.view import BaseView
class BareRepoNavigator:
login_required = True
def __init__(self, repo_path):
"""Initialize the navigator with a bare repository path."""
try:
self.repo = Repo(repo_path)
if not self.repo.bare:
print(f"Error: {repo_path} is not a bare repository.")
sys.exit(1)
except git.exc.InvalidGitRepositoryError:
print(f"Error: {repo_path} is not a valid Git repository.")
sys.exit(1)
except Exception as e:
print(f"Error opening repository: {str(e)}")
sys.exit(1)
self.repo_path = repo_path
self.branches = list(self.repo.branches)
self.current_branch = None
self.current_commit = None
self.current_path = ""
self.history = []
def get_branches(self):
"""Return a list of branch names in the repository."""
return [branch.name for branch in self.branches]
def set_branch(self, branch_name):
"""Set the current branch."""
try:
self.current_branch = self.repo.branches[branch_name]
self.current_commit = self.current_branch.commit
self.current_path = ""
self.history = []
return True
except IndexError:
return False
def get_commits(self, count=10):
"""Get the latest commits on the current branch."""
if not self.current_branch:
return []
commits = []
for commit in self.repo.iter_commits(self.current_branch, max_count=count):
commits.append(
{
"hash": commit.hexsha,
"short_hash": commit.hexsha[:7],
"message": commit.message.strip(),
"author": commit.author.name,
"date": datetime.fromtimestamp(commit.committed_date).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
return commits
def set_commit(self, commit_hash):
"""Set the current commit by hash."""
try:
self.current_commit = self.repo.commit(commit_hash)
self.current_path = ""
self.history = []
return True
except ValueError:
return False
def list_directory(self, path=""):
"""List the contents of a directory in the current commit."""
if not self.current_commit:
return {"dirs": [], "files": []}
dirs = []
files = []
try:
# Get the tree at the current path
if path:
tree = self.current_commit.tree[path]
if not hasattr(tree, "trees"): # It's a blob, not a tree
return {"dirs": [], "files": [path]}
else:
tree = self.current_commit.tree
# List directories and files
for item in tree:
if item.type == "tree":
item_path = os.path.join(path, item.name) if path else item.name
dirs.append(item_path)
elif item.type == "blob":
item_path = os.path.join(path, item.name) if path else item.name
files.append(item_path)
dirs.sort()
files.sort()
return {"dirs": dirs, "files": files}
except KeyError:
return {"dirs": [], "files": []}
def get_file_content(self, file_path):
"""Get the content of a file in the current commit."""
if not self.current_commit:
return None
try:
blob = self.current_commit.tree[file_path]
return blob.data_stream.read().decode("utf-8", errors="replace")
except (KeyError, UnicodeDecodeError):
try:
# Try to get as binary if text decoding fails
blob = self.current_commit.tree[file_path]
return blob.data_stream.read()
except:
return None
def navigate_to(self, path):
"""Navigate to a specific path, updating the current path."""
if not self.current_commit:
return False
try:
if path:
self.current_commit.tree[path] # Check if path exists
self.history.append(self.current_path)
self.current_path = path
return True
except KeyError:
return False
def navigate_back(self):
"""Navigate back to the previous path."""
if self.history:
self.current_path = self.history.pop()
return True
return False
class RepositoryView(BaseView):
login_required = True
def checkout_bare_repo(
self, bare_repo_path: Path, target_path: Path, ref: str = "HEAD"
):
repo = Repo(bare_repo_path)
assert repo.bare, "Repository is not bare."
commit = repo.commit(ref)
tree = commit.tree
for blob in tree.traverse():
target_file = target_path / blob.path
target_file.parent.mkdir(parents=True, exist_ok=True)
print(blob.path)
with open(target_file, "wb") as f:
f.write(blob.data_stream.read())
async def get(self):
base_repo_path = Path("drive/repositories")
authenticated_user_id = self.session.get("uid")
username = self.request.match_info.get("username")
repo_name = self.request.match_info.get("repository")
rel_path = self.request.match_info.get("path", "")
branch = self.request.query.get("branch", "")
commit_hash = self.request.query.get("commit", "")
user = None
if not username or username.count("-") != 4:
if not username.count("-") == 4:
user = await self.app.services.user.get(username=username)
if not user:
return web.Response(text="404 Not Found", status=404)
@ -35,262 +191,99 @@ class RepositoryView(BaseView):
else:
user = await self.app.services.user.get(uid=username)
if not user:
return web.Response(text="404 Not Found", status=404)
repo = await self.app.services.repository.get(
name=repo_name, user_uid=user["uid"]
)
if not repo:
return web.Response(text="404 Not Found", status=404)
if repo["is_private"] and authenticated_user_id != repo["uid"]:
return web.Response(text="404 Not Found", status=404)
if repo["is_private"] and authenticated_user_id != user["uid"]:
return web.Response(text="403 Forbidden", status=403)
repo_path = (
await self.app.services.user.get_repository_path(user["uid"])
) / (repo_name + ".git")
if not repo_path.exists():
return web.Response(text="404 Repository Not Found", status=404)
repo_root_base = (base_repo_path / user["uid"] / (repo_name + ".git")).resolve()
repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
try:
git_repo = git.Repo(repo_path)
except Exception:
return web.Response(text="500 Invalid Repository", status=500)
if not git_repo.bare:
return web.Response(text="500 Repository must be bare", status=500)
try:
branches = [b.name for b in git_repo.branches]
except Exception:
branches = []
if not branches:
return await self.render_template(
"repository_empty.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"clone_url": self.get_clone_url(username, repo_name),
},
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, self.checkout_bare_repo, repo_root_base, repo_root
)
except:
pass
if commit_hash:
try:
current_commit = git_repo.commit(commit_hash)
current_branch = commit_hash[:7]
except Exception:
return web.Response(text="404 Commit Not Found", status=404)
elif branch:
try:
current_commit = git_repo.branches[branch].commit
current_branch = branch
except Exception:
return web.Response(text="404 Branch Not Found", status=404)
else:
try:
current_branch = git_repo.active_branch.name
current_commit = git_repo.active_branch.commit
except Exception:
try:
current_branch = branches[0]
current_commit = git_repo.branches[branches[0]].commit
except Exception:
current_branch = "HEAD"
current_commit = git_repo.head.commit
if not repo_root.exists() or not repo_root.is_dir():
return web.Response(text="404 Not Found", status=404)
if not rel_path:
commits = []
try:
for commit in list(
git_repo.iter_commits(current_commit, max_count=10)
):
commits.append(
{
"hash": commit.hexsha,
"short_hash": commit.hexsha[:7],
"message": commit.message.strip().split("\n")[0],
"author": commit.author.name,
"date": datetime.fromtimestamp(
commit.committed_date
).strftime("%Y-%m-%d %H:%M"),
}
)
except Exception:
commits = []
safe_rel_path = os.path.normpath(rel_path).lstrip(os.sep)
abs_path = (repo_root / safe_rel_path).resolve()
try:
tree = current_commit.tree
items = []
if not abs_path.exists() or not abs_path.is_relative_to(repo_root):
return web.Response(text="404 Not Found", status=404)
for item in tree:
items.append(
{
"name": item.name,
"path": item.path,
"type": item.type,
"size": item.size if item.type == "blob" else 0,
"is_dir": item.type == "tree",
}
)
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
except Exception:
items = []
readme_content = None
try:
for readme_name in ["README.md", "README", "README.txt", "readme.md"]:
try:
blob = current_commit.tree[readme_name]
content = blob.data_stream.read().decode("utf-8", errors="replace")
if readme_name.endswith(".md"):
import mistune
readme_content = mistune.html(content)
else:
readme_content = f"<pre>{content}</pre>"
break
except Exception:
continue
except Exception:
pass
return await self.render_template(
"repository_overview.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"branches": branches,
"current_branch": current_branch,
"commits": commits,
"items": items,
"rel_path": "",
"clone_url": self.get_clone_url(username, repo_name),
"readme_content": readme_content,
},
if abs_path.is_dir():
return web.Response(
text=self.render_directory(
abs_path, username, repo_name, safe_rel_path
),
content_type="text/html",
)
else:
try:
tree = current_commit.tree
item = tree[rel_path]
return web.Response(
text=self.render_file(abs_path), content_type="text/html"
)
if item.type == "tree":
items = []
for child in item:
items.append(
{
"name": child.name,
"path": child.path,
"type": child.type,
"size": child.size if child.type == "blob" else 0,
"is_dir": child.type == "tree",
}
)
def render_directory(self, abs_path, username, repo_name, safe_rel_path):
entries = sorted(
abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
)
items = []
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
if safe_rel_path:
parent_path = Path(safe_rel_path).parent
parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip(
"/"
)
items.append(f'<li><a href="{parent_link}">⬅️ ..</a></li>')
parent_path = str(Path(rel_path).parent) if rel_path != "." else ""
if parent_path == ".":
parent_path = ""
for entry in entries:
link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip("/")
display = entry.name + ("/" if entry.is_dir() else "")
size = "" if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
icon = self.get_icon(entry)
items.append(f'<li>{icon} <a href="{link}">{display}</a> {size}</li>')
return await self.render_template(
"repository_tree.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"branches": branches,
"current_branch": current_branch,
"items": items,
"rel_path": rel_path,
"parent_path": parent_path,
"clone_url": self.get_clone_url(username, repo_name),
},
)
else:
content = item.data_stream.read()
html = f"""
<html>
<head><title>📁 {repo_name}/{safe_rel_path}</title></head>
<body>
<h2>📁 {username}/{repo_name}/{safe_rel_path}</h2>
<ul>
{''.join(items)}
</ul>
</body>
</html>
"""
return html
try:
text_content = content.decode("utf-8")
is_binary = False
except UnicodeDecodeError:
text_content = None
is_binary = True
def render_file(self, abs_path):
try:
with open(abs_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
return f"<pre>{content}</pre>"
except Exception as e:
return f"<h1>Error</h1><pre>{e}</pre>"
mime_type = mimetypes.guess_type(item.name)[0] or "text/plain"
if is_binary:
if mime_type.startswith("image/"):
data_uri = f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
return await self.render_template(
"repository_file.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"branches": branches,
"current_branch": current_branch,
"file_path": rel_path,
"file_name": item.name,
"is_binary": True,
"is_image": True,
"image_data": data_uri,
"file_size": humanize.naturalsize(len(content)),
},
)
else:
return await self.render_template(
"repository_file.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"branches": branches,
"current_branch": current_branch,
"file_path": rel_path,
"file_name": item.name,
"is_binary": True,
"is_image": False,
"file_size": humanize.naturalsize(len(content)),
},
)
lines = text_content.split("\n")
return await self.render_template(
"repository_file.html",
{
"user": user.record,
"repo": repo.record,
"repo_name": repo_name,
"username": username,
"branches": branches,
"current_branch": current_branch,
"file_path": rel_path,
"file_name": item.name,
"is_binary": False,
"content": text_content,
"lines": lines,
"line_count": len(lines),
"file_size": humanize.naturalsize(len(content)),
},
)
except KeyError:
return web.Response(text="404 Path Not Found", status=404)
except Exception as e:
return web.Response(text=f"500 Error: {str(e)}", status=500)
def get_clone_url(self, username, repo_name):
host = self.request.host
return f"http://{host}/git/{username}/{repo_name}.git"
def get_icon(self, file):
if file.is_dir():
return "📁"
mime = mimetypes.guess_type(file.name)[0] or ""
if mime.startswith("image"):
return "🖼️"
if mime.startswith("text"):
return "📄"
if mime.startswith("audio"):
return "🎵"
if mime.startswith("video"):
return "🎬"
if file.name.endswith(".py"):
return "🐍"
return "📦"

View File

@ -6,11 +6,12 @@
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
from snek.system.stats import update_websocket_stats
import asyncio
import json
import logging
import traceback
import random
from aiohttp import web
from snek.system.model import now
@ -175,26 +176,6 @@ class RPCView(BaseView):
messages.append(extended_dict)
return messages
async def get_first_unread_message_uid(self, channel_uid):
self._require_login()
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return None
last_read_at = channel_member.get("last_read_at")
if not last_read_at:
return None
async for message in self.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
{"channel_uid": channel_uid, "last_read_at": last_read_at}
):
return message["uid"]
return None
async def get_channels(self):
self._require_login()
channels = []
@ -526,7 +507,9 @@ class RPCView(BaseView):
raise Exception("Method not found")
success = True
try:
update_websocket_stats(self.app)
result = await method(*args)
update_websocket_stats(self.app)
except Exception as ex:
result = {"exception": str(ex), "traceback": traceback.format_exc()}
success = False
@ -546,8 +529,8 @@ class RPCView(BaseView):
try:
await self.ws.send_str(json.dumps(obj, default=str))
except Exception as ex:
print("THIS IS THE DeAL>",str(ex), flush=True)
await self.services.socket.delete(self.ws)
await self.ws.close()
async def get_online_users(self, channel_uid):
self._require_login()
@ -655,7 +638,7 @@ class RPCView(BaseView):
try:
await rpc(msg.json())
except Exception as ex:
print("XXXXXXXXXX Deleting socket", ex, flush=True)
print("Deleting socket", ex, flush=True)
logger.exception(ex)
await self.services.socket.delete(ws)
break

View File

@ -1,128 +0,0 @@
import logging
from aiohttp import web
from snek.system.view import BaseView
from snek.system.exceptions import ValidationError, DuplicateResourceError
logger = logging.getLogger(__name__)
class ProfilePagesView(BaseView):
login_required = True
async def get(self):
pages = await self.services.profile_page.get_user_pages(
self.session.get("uid"),
include_unpublished=True
)
return await self.render_template(
"settings/profile_pages/index.html",
{"pages": pages}
)
class ProfilePageCreateView(BaseView):
login_required = True
async def get(self):
return await self.render_template("settings/profile_pages/create.html", {})
async def post(self):
data = await self.request.post()
title = data.get("title", "").strip()
content = data.get("content", "")
is_published = data.get("is_published") == "on"
if not title:
return await self.render_template(
"settings/profile_pages/create.html",
{"error": "Title is required"}
)
try:
await self.services.profile_page.create_page(
user_uid=self.session.get("uid"),
title=title,
content=content,
is_published=is_published
)
return web.HTTPFound("/settings/profile_pages/index.html")
except DuplicateResourceError as e:
return await self.render_template(
"settings/profile_pages/create.html",
{"error": str(e), "title": title, "content": content}
)
class ProfilePageEditView(BaseView):
login_required = True
async def get(self):
page_uid = self.request.match_info.get("page_uid")
page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
if not page or page["user_uid"] != self.session.get("uid"):
raise web.HTTPNotFound()
return await self.render_template(
"settings/profile_pages/edit.html",
{"page": page}
)
async def post(self):
page_uid = self.request.match_info.get("page_uid")
page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
if not page or page["user_uid"] != self.session.get("uid"):
raise web.HTTPNotFound()
data = await self.request.post()
title = data.get("title", "").strip()
content = data.get("content", "")
is_published = data.get("is_published") == "on"
if not title:
return await self.render_template(
"settings/profile_pages/edit.html",
{"page": page, "error": "Title is required"}
)
try:
await self.services.profile_page.update_page(
page_uid=page_uid,
title=title,
content=content,
is_published=is_published
)
return web.HTTPFound("/settings/profile_pages/index.html")
except DuplicateResourceError as e:
page["title"] = title
page["content"] = content
page["is_published"] = is_published
return await self.render_template(
"settings/profile_pages/edit.html",
{"page": page, "error": str(e)}
)
class ProfilePageDeleteView(BaseView):
login_required = True
async def post(self):
page_uid = self.request.match_info.get("page_uid")
page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
if not page or page["user_uid"] != self.session.get("uid"):
raise web.HTTPNotFound()
await self.services.profile_page.delete_page(page_uid)
return web.HTTPFound("/settings/profile_pages/index.html")
class ProfilePageReorderView(BaseView):
login_required = True
async def post(self):
data = await self.request.json()
page_uids = data.get("page_uids", [])
await self.services.profile_page.reorder_pages(
user_uid=self.session.get("uid"),
page_uids=page_uids
)
return web.json_response({"success": True})

View File

@ -36,7 +36,6 @@ class RepositoriesCreateView(BaseFormView):
await self.services.repository.create(
user_uid=self.session.get("uid"),
name=data["name"],
description=data.get("description", ""),
is_private=int(data.get("is_private", 0)),
)
return web.HTTPFound("/settings/repositories/index.html")
@ -62,7 +61,6 @@ class RepositoriesUpdateView(BaseFormView):
repository = await self.services.repository.get(
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
)
repository["description"] = data.get("description", "")
repository["is_private"] = int(data.get("is_private", 0))
await self.services.repository.save(repository)
return web.HTTPFound("/settings/repositories/index.html")

View File

@ -11,16 +11,7 @@ class UserView(BaseView):
profile_content = (
await self.services.user_property.get(user["uid"], "profile") or ""
)
profile_pages = await self.services.profile_page.get_user_pages(
user["uid"],
include_unpublished=False
)
return await self.render_template(
"user.html",
{
"user_uid": user_uid,
"user": user.record,
"profile": profile_content,
"profile_pages": profile_pages
},
{"user_uid": user_uid, "user": user.record, "profile": profile_content},
)

View File

@ -55,7 +55,7 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"] and not channel.is_dm:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),

File diff suppressed because it is too large Load Diff

78
src/snekssh/app.py Normal file
View File

@ -0,0 +1,78 @@
import asyncio
import logging
import os
import asyncssh
asyncssh.set_debug_level(2)
logging.basicConfig(level=logging.DEBUG)
# Configuration for SFTP server
SFTP_ROOT = "." # Directory to serve
USERNAME = "test"
PASSWORD = "woeii"
HOST = "localhost"
PORT = 2225
class MySFTPServer(asyncssh.SFTPServer):
def __init__(self, chan):
super().__init__(chan)
self.root = os.path.abspath(SFTP_ROOT)
async def stat(self, path):
"""Handles 'stat' command from SFTP client"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().stat(full_path)
async def open(self, path, flags, attrs):
"""Handles file open requests"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().open(full_path, flags, attrs)
async def listdir(self, path):
"""Handles directory listing"""
full_path = os.path.join(self.root, path.lstrip("/"))
return await super().listdir(full_path)
class MySSHServer(asyncssh.SSHServer):
"""Custom SSH server to handle authentication"""
def connection_made(self, conn):
print(f"New connection from {conn.get_extra_info('peername')}")
def connection_lost(self, exc):
print("Client disconnected")
def begin_auth(self, username):
return True # No additional authentication steps
def password_auth_supported(self):
return True # Support password authentication
def validate_password(self, username, password):
print(username, password)
return True
return username == USERNAME and password == PASSWORD
async def start_sftp_server():
os.makedirs(SFTP_ROOT, exist_ok=True) # Ensure the root directory exists
await asyncssh.create_server(
lambda: MySSHServer(),
host=HOST,
port=PORT,
server_host_keys=["ssh_host_key"],
process_factory=MySFTPServer,
)
print(f"SFTP server running on {HOST}:{PORT}")
await asyncio.Future() # Keep running forever
if __name__ == "__main__":
try:
asyncio.run(start_sftp_server())
except (OSError, asyncssh.Error) as e:
print(f"Error starting SFTP server: {e}")

77
src/snekssh/app2.py Normal file
View File

@ -0,0 +1,77 @@
import asyncio
import os
import asyncssh
# SSH Server Configuration
HOST = "0.0.0.0"
PORT = 2225
USERNAME = "user"
PASSWORD = "password"
SHELL = "/bin/sh" # Change to another shell if needed
class CustomSSHServer(asyncssh.SSHServer):
def connection_made(self, conn):
print(f"New connection from {conn.get_extra_info('peername')}")
def connection_lost(self, exc):
print("Client disconnected")
def password_auth_supported(self):
return True
def validate_password(self, username, password):
return username == USERNAME and password == PASSWORD
async def custom_bash_process(process):
"""Spawns a custom bash shell process"""
env = os.environ.copy()
env["TERM"] = "xterm-256color"
# Start the Bash shell
bash_proc = await asyncio.create_subprocess_exec(
SHELL,
"-i",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
async def read_output():
while True:
data = await bash_proc.stdout.read(1)
if not data:
break
process.stdout.write(data)
async def read_input():
while True:
data = await process.stdin.read(1)
if not data:
break
bash_proc.stdin.write(data)
await asyncio.gather(read_output(), read_input())
async def start_ssh_server():
"""Starts the AsyncSSH server with Bash"""
await asyncssh.create_server(
lambda: CustomSSHServer(),
host=HOST,
port=PORT,
server_host_keys=["ssh_host_key"],
process_factory=custom_bash_process,
)
print(f"SSH server running on {HOST}:{PORT}")
await asyncio.Future() # Keep running
if __name__ == "__main__":
try:
asyncio.run(start_ssh_server())
except (OSError, asyncssh.Error) as e:
print(f"Error starting SSH server: {e}")

74
src/snekssh/app3.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
#
# The file ``ssh_user_ca`` must exist with a cert-authority entry of
# the certificate authority which can sign valid client certificates.
import asyncio
import sys
import asyncssh
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
width, height, pixwidth, pixheight = process.term_size
process.stdout.write(
f"Terminal type: {process.term_type}, " f"size: {width}x{height}"
)
if pixwidth and pixheight:
process.stdout.write(f" ({pixwidth}x{pixheight} pixels)")
process.stdout.write("\nTry resizing your window!\n")
while not process.stdin.at_eof():
try:
await process.stdin.read()
except asyncssh.TerminalSizeChanged as exc:
process.stdout.write(f"New window size: {exc.width}x{exc.height}")
if exc.pixwidth and exc.pixheight:
process.stdout.write(f" ({exc.pixwidth}" f"x{exc.pixheight} pixels)")
process.stdout.write("\n")
async def start_server() -> None:
await asyncssh.listen(
"",
2230,
server_host_keys=["ssh_host_key"],
# authorized_client_keys='ssh_user_ca',
process_factory=handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()

90
src/snekssh/app4.py Normal file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
import asyncio
import sys
from typing import Optional
import asyncssh
import bcrypt
passwords = {
"guest": b"", # guest account with no password
"user": bcrypt.hashpw(b"user", bcrypt.gensalt()),
}
def handle_client(process: asyncssh.SSHServerProcess) -> None:
username = process.get_extra_info("username")
process.stdout.write(f"Welcome to my SSH server, {username}!\n")
# process.exit(0)
class MySSHServer(asyncssh.SSHServer):
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
peername = conn.get_extra_info("peername")[0]
print(f"SSH connection received from {peername}.")
def connection_lost(self, exc: Optional[Exception]) -> None:
if exc:
print("SSH connection error: " + str(exc), file=sys.stderr)
else:
print("SSH connection closed.")
def begin_auth(self, username: str) -> bool:
# If the user's password is the empty string, no auth is required
return passwords.get(username) != b""
def password_auth_supported(self) -> bool:
return True
def validate_password(self, username: str, password: str) -> bool:
if username not in passwords:
return False
pw = passwords[username]
if not password and not pw:
return True
return bcrypt.checkpw(password.encode("utf-8"), pw)
async def start_server() -> None:
await asyncssh.create_server(
MySSHServer,
"",
2231,
server_host_keys=["ssh_host_key"],
process_factory=handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()

112
src/snekssh/app5.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3.7
#
# Copyright (c) 2016-2024 by Ron Frederick <ronf@timeheart.net> and others.
#
# This program and the accompanying materials are made available under
# the terms of the Eclipse Public License v2.0 which accompanies this
# distribution and is available at:
#
# http://www.eclipse.org/legal/epl-2.0/
#
# This program may also be made available under the following secondary
# licenses when the conditions for such availability set forth in the
# Eclipse Public License v2.0 are satisfied:
#
# GNU General Public License, Version 2.0, or any later versions of
# that license
#
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
#
# Contributors:
# Ron Frederick - initial implementation, API, and documentation
# To run this program, the file ``ssh_host_key`` must exist with an SSH
# private key in it to use as a server host key. An SSH host certificate
# can optionally be provided in the file ``ssh_host_key-cert.pub``.
#
# The file ``ssh_user_ca`` must exist with a cert-authority entry of
# the certificate authority which can sign valid client certificates.
import asyncio
import sys
from typing import List, cast
import asyncssh
class ChatClient:
_clients: List["ChatClient"] = []
def __init__(self, process: asyncssh.SSHServerProcess):
self._process = process
@classmethod
async def handle_client(cls, process: asyncssh.SSHServerProcess):
await cls(process).run()
async def readline(self) -> str:
return cast(str, self._process.stdin.readline())
def write(self, msg: str) -> None:
self._process.stdout.write(msg)
def broadcast(self, msg: str) -> None:
for client in self._clients:
if client != self:
client.write(msg)
def begin_auth(self, username: str) -> bool:
# If the user's password is the empty string, no auth is required
# return False
return True # passwords.get(username) != b''
def password_auth_supported(self) -> bool:
return True
def validate_password(self, username: str, password: str) -> bool:
# if username not in passwords:
# return False
# pw = passwords[username]
# if not password and not pw:
# return True
return True
# return bcrypt.checkpw(password.encode('utf-8'), pw)
async def run(self) -> None:
self.write("Welcome to chat!\n\n")
self.write("Enter your name: ")
name = (await self.readline()).rstrip("\n")
self.write(f"\n{len(self._clients)} other users are connected.\n\n")
self._clients.append(self)
self.broadcast(f"*** {name} has entered chat ***\n")
try:
async for line in self._process.stdin:
self.broadcast(f"{name}: {line}")
except asyncssh.BreakReceived:
pass
self.broadcast(f"*** {name} has left chat ***\n")
self._clients.remove(self)
async def start_server() -> None:
await asyncssh.listen(
"",
2235,
server_host_keys=["ssh_host_key"],
process_factory=ChatClient.handle_client,
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_server())
except (OSError, asyncssh.Error) as exc:
sys.exit("Error starting server: " + str(exc))
loop.run_forever()