Compare commits
87 Commits
maintenanc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ad8f6cb31 | |||
| f8d650567a | |||
| 8db6f39046 | |||
| e4ebd8b4fd | |||
| efcd10c3c0 | |||
| 2a9b883e1b | |||
| f770dcf2db | |||
| 2deb8a2069 | |||
| 87e19e3d02 | |||
|
|
31a754d264 | ||
| 767048d79b | |||
| 13181fa275 | |||
|
|
37e872809b | ||
|
|
b99339ea93 | ||
| f0e68cb31e | |||
| 8fe372532b | |||
| 214ac33049 | |||
| 7b245869d5 | |||
| f5a2928f1b | |||
| f74146bb11 | |||
| f6157bf879 | |||
| 704adf6fe8 | |||
| f2575e04fb | |||
| ba7bbbfc62 | |||
| 1db9947267 | |||
| 38d05da272 | |||
| c740de95f9 | |||
| 483a63ede9 | |||
| a817b9b61d | |||
| 0c6da9845c | |||
| 586c653e4f | |||
| c8cc411aa5 | |||
| 71d967114d | |||
| 659c30f376 | |||
| 70d3b3b019 | |||
| 0b8c324944 | |||
| cbbaa22f56 | |||
| fb754cad92 | |||
| 0518cdce0b | |||
| 1af739cac2 | |||
| b28ba3c47d | |||
| fadc57a7c7 | |||
| cca3946a35 | |||
| 18be3fdc19 | |||
| 939e63f244 | |||
| b4c267d584 | |||
| b9b31a494a | |||
| b961954aa1 | |||
| 84287808c8 | |||
| 692272e3ca | |||
| 89d639e44e | |||
| e62da0aef1 | |||
| 3759306e38 | |||
| fcd91b4321 | |||
| cf32a78ef5 | |||
| 7c43d957bc | |||
| cc6a9ef9d3 | |||
| 1babfa0d64 | |||
| ce940b39b8 | |||
| 6151fc1dac | |||
| 338bdb5932 | |||
| bbcc845c26 | |||
| 1c080bc4be | |||
| 59b0494328 | |||
| 6337350b60 | |||
| 986acfac38 | |||
| b27149b5ba | |||
| eb1284060a | |||
| 4266ac1f12 | |||
| 6b4709d011 | |||
| ef8d3068a8 | |||
|
|
a23c14389b | ||
|
|
6dfd8db0a6 | ||
| abce2e03d1 | |||
| 54d7d5b74e | |||
| 17bb88050a | |||
|
|
8c2e20dfe8 | ||
|
|
3e2dd7ea04 | ||
|
|
70eebefac7 | ||
|
|
ac47d201d8 | ||
|
|
11e19f48e8 | ||
|
|
5ac49522d9 | ||
| f9f1179db5 | |||
| 04527c286f | |||
| e23d6571c8 | |||
| 0c331bbb93 | |||
| a2d506cce9 |
@ -39,7 +39,9 @@ dependencies = [
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
"IP2Location",
|
||||
"bleach"
|
||||
"bleach",
|
||||
"sentry-sdk",
|
||||
"bcrypt"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@ -50,3 +52,10 @@ where = ["src"] # <-- this changed
|
||||
|
||||
[project.scripts]
|
||||
snek = "snek.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-aiohttp"
|
||||
]
|
||||
|
||||
17
pytest.ini
Normal file
17
pytest.ini
Normal file
@ -0,0 +1,17 @@
|
||||
[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
|
||||
55
review.md
Normal file
55
review.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Project Quality Review: Snek
|
||||
|
||||
## Overview
|
||||
The Snek project is a comprehensive web-based application, functioning as a collaborative platform or chat system with extensive features including user management, channels, repositories, containers, real-time communication via WebSockets, Docker integration, Git support, and more. It leverages Python (primarily with aiohttp for the backend) and JavaScript for the frontend. The codebase is organized into modules such as app, mapper, model, service, view, system, and static assets. This review is based on a literal examination of every Python file in `src/snek/*` recursively, as their full contents have been provided.
|
||||
|
||||
## Strengths
|
||||
- **Modular Architecture**: The code exhibits strong separation of concerns with dedicated modules for mappers (data access), models (data structures), services (business logic), views (HTTP/WebSocket handlers), and system utilities. This promotes maintainability and scalability.
|
||||
- **Asynchronous Support**: Extensive use of async/await throughout (e.g., in `src/snek/app.py`, `src/snek/service/socket.py`, `src/snek/view/rpc.py`), effectively handling I/O-bound operations like WebSockets, database queries, and external API calls.
|
||||
- **Feature-Rich**: Supports a wide array of features, including user authentication, file uploads, terminal emulation, Docker container management, Git repositories, WebDAV, SSH, forums, push notifications, and avatar generation. Integration with external tools like Docker and Git is notable.
|
||||
- **Frontend Components**: Custom JavaScript components (e.g., in `src/snek/static/njet.js`) provide a modern, component-based UI with event handling and REST clients.
|
||||
- **Caching and Utilities**: Robust caching implementation (e.g., in `src/snek/system/cache.py`) with LRU eviction, and utility services (e.g., `src/snek/service/util.py`) enhance performance and reusability.
|
||||
- **Model and Field System**: A sophisticated model system (e.g., in `src/snek/system/model.py`) with typed fields (e.g., UUIDField, CreatedField) ensures data integrity and validation.
|
||||
- **Extensibility**: The codebase includes hooks for extensions (e.g., Jinja2 extensions in `src/snek/system/markdown.py`, `src/snek/system/template.py`) and sub-applications (e.g., forum in `src/snek/forum.py`).
|
||||
- **Error Handling in Places**: Some areas show good error handling (e.g., try-except in `src/snek/service/socket.py`, `src/snek/view/rpc.py`), though not universal.
|
||||
|
||||
## Weaknesses
|
||||
- **Syntax Errors**:
|
||||
- In `src/snek/research/serpentarium.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` is misspelled; it should be `setattr`. Additionally, the line ends with `)`, which is misplaced and causes a syntax error. Line 26: `self.setattr(self, "db", self.set)` – similar misspelling and incorrect assignment (self.set is not defined).
|
||||
- In `src/snek/sync.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` misspelled and misplaced `)`. Line 26: `self.setattr(self, "db", self.set)` – same issues. Line 27: `super()` – called without arguments, which may fail in classes with multiple inheritance or if arguments are expected.
|
||||
- **Security Concerns**:
|
||||
- **Injection Vulnerabilities**: Raw SQL queries without parameterization (e.g., in `src/snek/service/channel.py`, `src/snek/service/user.py`, `src/snek/view/rpc.py`) risk SQL injection. For example, in `src/snek/service/channel.py`, queries like `f"SELECT ... WHERE channel_uid=:channel_uid {history_start_filter}"` use string formatting, but not all are parameterized.
|
||||
- **Authentication Gaps**: Some WebSocket and RPC endpoints lack visible authentication checks (e.g., in `src/snek/view/rpc.py`, methods like `echo` and `query` don't enforce login). Basic auth in `src/snek/webdav.py` and `src/snek/sssh.py` is present but hardcoded.
|
||||
- **Input Validation**: Minimal input sanitization (e.g., in `src/snek/system/form.py`, forms have placeholders but no regex or length checks enforced). User inputs in RPC calls (e.g., `src/snek/view/rpc.py`) are not validated.
|
||||
- **Hardcoded Secrets**: Database paths (e.g., 'sqlite:///snek.db' in multiple files), keys (e.g., SESSION_KEY in `src/snek/app.py`), and credentials are hardcoded, posing risks if exposed.
|
||||
- **Privilege Escalation**: Admin checks are inconsistent (e.g., in `src/snek/service/channel.py`, `src/snek/view/rpc.py`), and some operations (e.g., clearing channels) only check `is_admin` without further validation.
|
||||
- **WebDAV and SSH**: `src/snek/webdav.py` and `src/snek/sssh.py` handle file access but lack rate limiting or detailed permission checks.
|
||||
- **Code Quality Issues**:
|
||||
- **Inconsistent Naming and Style**: Mixed camelCase and snake_case (e.g., `set_typing` vs. `generateUniqueId` in JS). Some classes have similar names (e.g., `DatasetWebSocketView` in research files).
|
||||
- **Lack of Documentation**: Few docstrings or comments (e.g., no docstrings in `src/snek/service/user.py`, `src/snek/view/channel.py`). Methods are often self-explanatory but lack context.
|
||||
- **Hardcoded Values**: URLs, paths, and constants are hardcoded (e.g., in `src/snek/static/njet.js`, `src/snek/app.py`).
|
||||
- **Global State and Side Effects**: Use of global variables (e.g., in `src/snek/system/markdown.py`, `src/snek/view/rpc.py`) and mutable defaults can lead to bugs.
|
||||
- **Performance Issues**: No visible optimization for large datasets (e.g., in `src/snek/service/channel_message.py`, queries could be inefficient). Process pools in `src/snek/service/channel_message.py` are per-message, potentially wasteful.
|
||||
- **Error Handling Gaps**: Many methods lack try-except (e.g., in `src/snek/system/docker.py`, `src/snek/system/terminal.py`). Exceptions are sometimes caught but not logged properly.
|
||||
- **Dependencies**: Imports like `dataset`, `git`, `pymongo` (implied) are not version-pinned, risking compatibility issues.
|
||||
- **Testing Absence**: No visible unit or integration tests in the codebase.
|
||||
- **Potential Bugs**:
|
||||
- In `src/snek/research/serpentarium.py`, `self.setattr(self, "db", self.set)` assigns undefined `self.set`.
|
||||
- In `src/snek/sync.py`, similar assignment issues.
|
||||
- In `src/snek/view/rpc.py`, `self.user_uid` property assumes `self.view.session.get("uid")`, but no null checks.
|
||||
- In `src/snek/system/docker.py`, `ComposeFileManager` uses subprocess without full error handling.
|
||||
- In `src/snek/service/channel_message.py`, executor pools per UID could lead to resource leaks if not cleaned up.
|
||||
- In `src/snek/forum.py`, event listeners are added but no removal logic is visible.
|
||||
- **Maintainability**: Large files (e.g., `src/snek/view/rpc.py` is over 1000 lines) and complex methods (e.g., in `src/snek/app.py`) make refactoring hard. Some code duplication (e.g., WebSocket handling in multiple views).
|
||||
|
||||
## Recommendations
|
||||
- **Fix Syntax Errors Immediately**: Correct `setattr` spellings, remove misplaced `)`, and fix `super()` calls in `src/snek/research/serpentarium.py` and `src/snek/sync.py` to prevent runtime failures.
|
||||
- **Enhance Security**: Implement parameterized queries, add input validation (e.g., using regex in `src/snek/system/form.py`), enforce authentication in all endpoints, and use environment variables for secrets.
|
||||
- **Improve Code Quality**: Add docstrings, comments, and consistent naming. Refactor large methods and remove hardcoded values.
|
||||
- **Add Error Handling and Testing**: Wrap risky operations in try-except, log errors, and introduce unit tests (e.g., using pytest).
|
||||
- **Optimize Performance**: Review query efficiency, add indexing, and manage resources (e.g., executor pools).
|
||||
- **Address Bugs**: Fix undefined assignments and add null checks.
|
||||
- **General**: Pin dependencies, review for race conditions in async code, and consider code reviews or linters (e.g., flake8, mypy).
|
||||
|
||||
## Grade
|
||||
B- (Solid foundation with good architecture and features, but critical syntax errors, security vulnerabilities, and quality issues require immediate attention to avoid production risks.)
|
||||
@ -1,3 +1,7 @@
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
import pathlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
@ -9,6 +13,8 @@ from snek.shell import Shell
|
||||
from snek.app import Application
|
||||
|
||||
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
@ -122,6 +128,12 @@ 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()
|
||||
|
||||
|
||||
|
||||
@ -13,6 +13,9 @@ 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
|
||||
@ -40,6 +43,7 @@ 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
|
||||
@ -69,6 +73,13 @@ from snek.view.settings.repositories import (
|
||||
RepositoriesIndexView,
|
||||
RepositoriesUpdateView,
|
||||
)
|
||||
from snek.view.settings.profile_pages import (
|
||||
ProfilePagesView,
|
||||
ProfilePageCreateView,
|
||||
ProfilePageEditView,
|
||||
ProfilePageDeleteView,
|
||||
)
|
||||
from snek.view.profile_page import ProfilePageView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
@ -125,11 +136,23 @@ 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 = [
|
||||
cors_middleware,
|
||||
web.normalize_path_middleware(merge_slashes=True),
|
||||
ip2location_middleware,
|
||||
csp_middleware,
|
||||
]
|
||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||
@ -172,6 +195,8 @@ class Application(BaseApplication):
|
||||
self.on_startup.append(self.start_user_availability_service)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
self.on_startup.append(self.prepare_database)
|
||||
self.on_startup.append(self.create_default_forum)
|
||||
|
||||
|
||||
@property
|
||||
def uptime_seconds(self):
|
||||
@ -261,6 +286,8 @@ 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)
|
||||
@ -279,9 +306,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)
|
||||
@ -289,27 +316,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/{drive}.json", DriveView)
|
||||
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
self.router.add_view("/stats.json", StatsView)
|
||||
self.router.add_view("/user/{user}.html", UserView)
|
||||
self.router.add_view("/user/{user_uid}/{slug}.html", ProfilePageView)
|
||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||
self.router.add_view(
|
||||
"/repository/{username}/{repository}/{path:.*}", RepositoryView
|
||||
@ -326,6 +354,14 @@ class Application(BaseApplication):
|
||||
"/settings/repositories/repository/{name}/delete.html",
|
||||
RepositoriesDeleteView,
|
||||
)
|
||||
self.router.add_view("/settings/profile_pages/index.html", ProfilePagesView)
|
||||
self.router.add_view("/settings/profile_pages/create.html", ProfilePageCreateView)
|
||||
self.router.add_view(
|
||||
"/settings/profile_pages/{page_uid}/edit.html", ProfilePageEditView
|
||||
)
|
||||
self.router.add_view(
|
||||
"/settings/profile_pages/{page_uid}/delete.html", ProfilePageDeleteView
|
||||
)
|
||||
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
|
||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||
self.router.add_view(
|
||||
@ -362,6 +398,8 @@ 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 = {}
|
||||
@ -422,10 +460,12 @@ 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", "")
|
||||
|
||||
|
||||
@ -78,29 +78,7 @@ class ForumApplication(aiohttp.web.Application):
|
||||
|
||||
async def serve_forum_html(self, request):
|
||||
"""Serve the forum HTML with the web component"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Forum</title>
|
||||
<script type="module" src="/forum/static/snek-forum.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<snek-forum></snek-forum>
|
||||
</body>
|
||||
</html>"""
|
||||
return await self.parent.render_template("forum.html", request)
|
||||
|
||||
|
||||
#return aiohttp.web.Response(text=html, content_type="text/html")
|
||||
|
||||
|
||||
# Integration with main app
|
||||
|
||||
@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.profile_page import ProfilePageMapper
|
||||
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
@ -36,6 +37,7 @@ def get_mappers(app=None):
|
||||
"thread": ThreadMapper(app=app),
|
||||
"post": PostMapper(app=app),
|
||||
"post_like": PostLikeMapper(app=app),
|
||||
"profile_page": ProfilePageMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
9
src/snek/mapper/profile_page.py
Normal file
9
src/snek/mapper/profile_page.py
Normal file
@ -0,0 +1,9 @@
|
||||
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
|
||||
@ -12,6 +12,10 @@ 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 = ""
|
||||
@ -19,7 +23,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 created_at DESC LIMIT 1",
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
|
||||
{"channel_uid": self["uid"]},
|
||||
):
|
||||
|
||||
|
||||
12
src/snek/model/profile_page.py
Normal file
12
src/snek/model/profile_page.py
Normal file
@ -0,0 +1,12 @@
|
||||
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)
|
||||
@ -7,4 +7,6 @@ class RepositoryModel(BaseModel):
|
||||
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
|
||||
description = ModelField(name="description", required=False, kind=str)
|
||||
|
||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||
|
||||
@ -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="path", required=True, kind=str)
|
||||
value = ModelField(name="value", required=True, kind=str)
|
||||
|
||||
@ -1,41 +1,6 @@
|
||||
|
||||
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
|
||||
@ -47,196 +12,21 @@ 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')
|
||||
self.setattr(self, "db", self.get)
|
||||
self.setattr(self, "db", self.set)
|
||||
)
|
||||
super()
|
||||
|
||||
setattr(self, "db", self.get)
|
||||
setattr(self, "db", self.set)
|
||||
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())
|
||||
|
||||
@ -1,32 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS http_access (
|
||||
id INTEGER NOT NULL,
|
||||
created TEXT,
|
||||
path TEXT,
|
||||
duration FLOAT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
CREATE TABLE user (
|
||||
id INTEGER NOT NULL,
|
||||
city TEXT,
|
||||
color TEXT,
|
||||
country_long TEXT,
|
||||
country_short TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
email TEXT,
|
||||
ip TEXT,
|
||||
is_admin TEXT,
|
||||
last_ping TEXT,
|
||||
latitude TEXT,
|
||||
longitude TEXT,
|
||||
nick TEXT,
|
||||
password TEXT,
|
||||
region TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
username TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel (
|
||||
CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
|
||||
CREATE TABLE channel (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
created_by_uid TEXT,
|
||||
deleted_at TEXT,
|
||||
description TEXT,
|
||||
history_start TEXT,
|
||||
"index" BIGINT,
|
||||
is_listed BOOLEAN,
|
||||
is_private BOOLEAN,
|
||||
@ -37,8 +38,8 @@ CREATE TABLE IF NOT EXISTS channel (
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||
CREATE TABLE IF NOT EXISTS channel_member (
|
||||
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||
CREATE TABLE channel_member (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
@ -54,28 +55,31 @@ CREATE TABLE IF NOT EXISTS channel_member (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
|
||||
CREATE TABLE IF NOT EXISTS broadcast (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channel_message (
|
||||
CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
|
||||
CREATE TABLE channel_message (
|
||||
id INTEGER NOT NULL,
|
||||
channel_uid TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
html TEXT,
|
||||
is_final BOOLEAN,
|
||||
message TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||
CREATE INDEX ix_channel_message_acb69f257bb37684 ON channel_message (is_final, user_uid, channel_uid);
|
||||
CREATE INDEX ix_channel_message_c6c4cddf281df93d ON channel_message (deleted_at);
|
||||
CREATE TABLE kv (
|
||||
id INTEGER NOT NULL,
|
||||
"key" TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_kv_a62f2225bf70bfac ON kv ("key");
|
||||
CREATE TABLE notification (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
@ -88,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||
CREATE TABLE IF NOT EXISTS repository (
|
||||
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||
CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
|
||||
CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
|
||||
CREATE TABLE drive (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
name TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_drive_e2577dd78b54fe28 ON drive (uid);
|
||||
CREATE TABLE push_registration (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
endpoint TEXT,
|
||||
key_auth TEXT,
|
||||
key_p256dh TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_push_registration_e2577dd78b54fe28 ON push_registration (uid);
|
||||
CREATE TABLE repository (
|
||||
id INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
description TEXT,
|
||||
is_private BIGINT,
|
||||
name TEXT,
|
||||
uid TEXT,
|
||||
@ -100,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
|
||||
CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
|
||||
CREATE TABLE profile_page (
|
||||
id INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
is_published BOOLEAN,
|
||||
order_index BIGINT,
|
||||
slug TEXT,
|
||||
title TEXT,
|
||||
uid TEXT,
|
||||
updated_at TEXT,
|
||||
user_uid TEXT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);
|
||||
|
||||
@ -19,6 +19,7 @@ from snek.service.util import UtilService
|
||||
from snek.system.object import Object
|
||||
from snek.service.statistics import StatisticsService
|
||||
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
|
||||
from snek.service.profile_page import ProfilePageService
|
||||
_service_registry = {}
|
||||
|
||||
def register_service(name, service_cls):
|
||||
@ -62,4 +63,5 @@ register_service("forum", ForumService)
|
||||
register_service("thread", ThreadService)
|
||||
register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
register_service("profile_page", ProfilePageService)
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.model import now
|
||||
|
||||
|
||||
class ChannelMemberService(BaseService):
|
||||
@ -8,6 +9,7 @@ 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):
|
||||
|
||||
@ -1,5 +1,20 @@
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.template import whitelist_attributes
|
||||
from snek.system.template import sanitize_html
|
||||
import time
|
||||
import asyncio
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
global jinja2_env
|
||||
import pathlib
|
||||
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):
|
||||
@ -8,25 +23,50 @@ 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 = {}
|
||||
async for message in self.find():
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"uid": message["uid"],
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
if html != message["html"]:
|
||||
print("Reredefined message", message["uid"])
|
||||
return
|
||||
for message in self.mapper.db["channel_message"].find():
|
||||
print(message)
|
||||
try:
|
||||
message = await self.get(uid=message["uid"])
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"uid": message["uid"],
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
if html != message["html"]:
|
||||
print("Reredefined message", message["uid"])
|
||||
|
||||
except Exception as ex:
|
||||
time.sleep(0.1)
|
||||
print(ex, flush=True)
|
||||
|
||||
|
||||
while True:
|
||||
changed = 0
|
||||
async for message in self.find(is_final=False):
|
||||
@ -61,10 +101,14 @@ class ChannelMessageService(BaseService):
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
model["html"] = whitelist_attributes(model["html"])
|
||||
|
||||
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'])
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
|
||||
@ -83,6 +127,8 @@ 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}.")
|
||||
|
||||
@ -91,10 +137,10 @@ class ChannelMessageService(BaseService):
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
if not message["html"].startswith("<chat-message"):
|
||||
await (await self.get(uid=message["uid"])).save()
|
||||
message["html"] = (await self.get(uid=message["uid"])).html
|
||||
|
||||
#if not message["html"].startswith("<chat-message"):
|
||||
#message = await self.get(uid=message["uid"])
|
||||
#await self.save(message)
|
||||
|
||||
return {
|
||||
"uid": message["uid"],
|
||||
"color": user["color"],
|
||||
@ -119,10 +165,15 @@ class ChannelMessageService(BaseService):
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
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)
|
||||
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
|
||||
|
||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
|
||||
@ -4,6 +4,8 @@ import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Any, Awaitable, Callable, Dict, List
|
||||
import asyncio
|
||||
import inspect
|
||||
from snek.system.model import now
|
||||
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
||||
class BaseForumService(BaseService):
|
||||
@ -42,10 +44,12 @@ class BaseForumService(BaseService):
|
||||
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
||||
"""Invoke every listener for the given event."""
|
||||
for listener in self._listeners.get(event_name, []):
|
||||
if hasattr(listener, "__await__"): # async function or coro
|
||||
if inspect.iscoroutinefunction(listener):
|
||||
await listener(event_name, data)
|
||||
else: # plain sync function
|
||||
listener(event_name, data)
|
||||
else:
|
||||
result = listener(event_name, data)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def notify(self, event_name: str, data: Any) -> None:
|
||||
"""
|
||||
|
||||
85
src/snek/service/profile_page.py
Normal file
85
src/snek/service/profile_page.py
Normal file
@ -0,0 +1,85 @@
|
||||
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)
|
||||
@ -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)
|
||||
).joinpath(name + ".git")
|
||||
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):
|
||||
async def create(self, user_uid, name, is_private=False, description=None):
|
||||
if await self.exists(user_uid=user_uid, name=name):
|
||||
return False
|
||||
|
||||
@ -50,4 +50,14 @@ class RepositoryService(BaseService):
|
||||
model["user_uid"] = user_uid
|
||||
model["name"] = name
|
||||
model["is_private"] = is_private
|
||||
model["description"] = description or ""
|
||||
return await self.save(model)
|
||||
|
||||
async def list_by_user(self, user_uid):
|
||||
repositories = []
|
||||
async for repo in self.find(user_uid=user_uid):
|
||||
repositories.append(repo)
|
||||
return repositories
|
||||
|
||||
async def get_by_name(self, user_uid, name):
|
||||
return await self.get(user_uid=user_uid, name=name)
|
||||
|
||||
@ -44,19 +44,29 @@ 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):
|
||||
@ -77,7 +87,7 @@ class SocketService(BaseService):
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
count = 0
|
||||
for s in self.users.get(user_uid, []):
|
||||
for s in list(self.users.get(user_uid, [])):
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@ -15,6 +15,7 @@ class UserPropertyService(BaseService):
|
||||
},
|
||||
["user_uid", "name"],
|
||||
)
|
||||
self.mapper.db.commit()
|
||||
|
||||
async def get(self, user_uid, name):
|
||||
try:
|
||||
|
||||
233
src/snek/sgit.py
233
src/snek/sgit.py
@ -16,61 +16,72 @@ logger = logging.getLogger("git_server")
|
||||
|
||||
class GitApplication(web.Application):
|
||||
def __init__(self, parent=None):
|
||||
# import git
|
||||
# globals()['git'] = git
|
||||
import git
|
||||
globals()['git'] = git
|
||||
self.parent = parent
|
||||
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
|
||||
self.add_routes(
|
||||
[
|
||||
web.post("/create/{repo_name}", self.create_repository),
|
||||
web.delete("/delete/{repo_name}", self.delete_repository),
|
||||
web.get("/clone/{repo_name}", self.clone_repository),
|
||||
web.post("/push/{repo_name}", self.push_repository),
|
||||
web.post("/pull/{repo_name}", self.pull_repository),
|
||||
web.get("/status/{repo_name}", self.status_repository),
|
||||
# web.get('/list', self.list_repositories),
|
||||
web.get("/branches/{repo_name}", self.list_branches),
|
||||
web.post("/branches/{repo_name}", self.create_branch),
|
||||
web.get("/log/{repo_name}", self.commit_log),
|
||||
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
|
||||
web.get("/{path:.+}/info/refs", self.git_smart_http),
|
||||
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
|
||||
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
|
||||
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}/create", self.create_repository),
|
||||
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
|
||||
web.get("/{username}/{repo_name}/clone", self.clone_repository),
|
||||
web.post("/{username}/{repo_name}/push", self.push_repository),
|
||||
web.post("/{username}/{repo_name}/pull", self.pull_repository),
|
||||
web.get("/{username}/{repo_name}/status", self.status_repository),
|
||||
web.get("/{username}/{repo_name}/branches", self.list_branches),
|
||||
web.post("/{username}/{repo_name}/branches", self.create_branch),
|
||||
web.get("/{username}/{repo_name}/log", self.commit_log),
|
||||
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
|
||||
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
|
||||
web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
|
||||
]
|
||||
)
|
||||
|
||||
async def check_basic_auth(self, request):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
return None, None
|
||||
return None, None, None
|
||||
encoded_creds = auth_header.split("Basic ")[1]
|
||||
decoded_creds = base64.b64decode(encoded_creds).decode()
|
||||
username, password = decoded_creds.split(":", 1)
|
||||
request["user"] = await self.parent.services.user.authenticate(
|
||||
request["auth_user"] = await self.parent.services.user.authenticate(
|
||||
username=username, password=password
|
||||
)
|
||||
if not request["user"]:
|
||||
return None, None
|
||||
if not request["auth_user"]:
|
||||
return None, None, None
|
||||
|
||||
path_username = request.match_info.get("username")
|
||||
if not path_username:
|
||||
return None, None, None
|
||||
|
||||
if path_username.count("-") == 4:
|
||||
target_user = await self.parent.services.user.get(uid=path_username)
|
||||
else:
|
||||
target_user = await self.parent.services.user.get(username=path_username)
|
||||
|
||||
if not target_user:
|
||||
return None, None, None
|
||||
|
||||
request["target_user"] = target_user
|
||||
request["repository_path"] = (
|
||||
await self.parent.services.user.get_repository_path(request["user"]["uid"])
|
||||
await self.parent.services.user.get_repository_path(target_user["uid"])
|
||||
)
|
||||
|
||||
return request["user"]["username"], request["repository_path"]
|
||||
return request["auth_user"]["username"], target_user, request["repository_path"]
|
||||
|
||||
@staticmethod
|
||||
def require_auth(handler):
|
||||
async def wrapped(self, request, *args, **kwargs):
|
||||
username, repository_path = await self.check_basic_auth(request)
|
||||
if not username or not repository_path:
|
||||
username, target_user, repository_path = await self.check_basic_auth(request)
|
||||
if not username or not target_user or not repository_path:
|
||||
return web.Response(
|
||||
status=401,
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
text="Authentication required",
|
||||
)
|
||||
request["username"] = username
|
||||
request["target_user"] = target_user
|
||||
request["repository_path"] = repository_path
|
||||
return await handler(self, request, *args, **kwargs)
|
||||
|
||||
@ -87,9 +98,17 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def create_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only create repositories in your own namespace",
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not repo_name or "/" in repo_name or ".." in repo_name:
|
||||
return web.Response(text="Invalid repository name", status=400)
|
||||
repo_dir = self.repo_path(repository_path, repo_name)
|
||||
@ -97,7 +116,7 @@ class GitApplication(web.Application):
|
||||
return web.Response(text="Repository already exists", status=400)
|
||||
try:
|
||||
git.Repo.init(repo_dir, bare=True)
|
||||
logger.info(f"Created repository: {repo_name} for user {username}")
|
||||
logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Created repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
||||
@ -105,16 +124,22 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def delete_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only delete your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
#'''
|
||||
try:
|
||||
shutil.rmtree(self.repo_path(repository_path, repo_name))
|
||||
logger.info(f"Deleted repository: {repo_name} for user {username}")
|
||||
logger.info(f"Deleted repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Deleted repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
||||
@ -122,9 +147,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def clone_repository(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -139,9 +175,16 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def push_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only push to your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -175,14 +218,21 @@ class GitApplication(web.Application):
|
||||
temp_repo.index.commit(commit_message)
|
||||
origin = temp_repo.remote("origin")
|
||||
origin.push(refspec=f"{branch}:{branch}")
|
||||
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
||||
logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
|
||||
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||
|
||||
@require_auth
|
||||
async def pull_repository(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only pull to your own repositories", status=403
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -210,7 +260,7 @@ class GitApplication(web.Application):
|
||||
origin = local_repo.remote("origin")
|
||||
origin.push()
|
||||
logger.info(
|
||||
f"Pulled to repository {repo_name} from {remote_url} for user {username}"
|
||||
f"Pulled to repository {repo_name} from {remote_url} for user {auth_user['username']}"
|
||||
)
|
||||
return web.Response(
|
||||
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
|
||||
@ -221,9 +271,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def status_repository(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -291,9 +352,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def list_branches(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -306,9 +378,17 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def create_branch(self, request):
|
||||
username = request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
if auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(
|
||||
text="Forbidden: can only create branches in your own repositories",
|
||||
status=403,
|
||||
)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -328,7 +408,7 @@ class GitApplication(web.Application):
|
||||
temp_repo.git.branch(branch_name, start_point)
|
||||
temp_repo.git.push("origin", branch_name)
|
||||
logger.info(
|
||||
f"Created branch {branch_name} in repository {repo_name} for user {username}"
|
||||
f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
|
||||
)
|
||||
return web.Response(text=f"Created branch {branch_name}")
|
||||
except Exception as e:
|
||||
@ -339,9 +419,20 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def commit_log(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -383,11 +474,22 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def file_content(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repo_name = request.match_info["repo_name"]
|
||||
file_path = request.match_info.get("file_path", "")
|
||||
branch = request.query.get("branch", "main")
|
||||
repository_path = request["repository_path"]
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
@ -433,25 +535,42 @@ class GitApplication(web.Application):
|
||||
|
||||
@require_auth
|
||||
async def git_smart_http(self, request):
|
||||
request["username"]
|
||||
auth_user = request["auth_user"]
|
||||
target_user = request["target_user"]
|
||||
repository_path = request["repository_path"]
|
||||
repo_name = request.match_info.get("repo_name")
|
||||
path_username = request.match_info.get("username")
|
||||
path = request.path
|
||||
|
||||
repo = await self.parent.services.repository.get(
|
||||
user_uid=target_user["uid"], name=repo_name
|
||||
)
|
||||
if not repo:
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
is_owner = auth_user["uid"] == target_user["uid"]
|
||||
is_write_operation = "/git-receive-pack" in path
|
||||
|
||||
if is_write_operation and not is_owner:
|
||||
logger.warning(
|
||||
f"Push denied: {auth_user['username']} attempted to push to {path_username}/{repo_name}"
|
||||
)
|
||||
return web.Response(
|
||||
text="Push denied: only repository owner can push", status=403
|
||||
)
|
||||
|
||||
if not is_owner and repo["is_private"]:
|
||||
logger.warning(
|
||||
f"Access denied: {auth_user['username']} attempted to access private repo {path_username}/{repo_name}"
|
||||
)
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
|
||||
async def get_repository_path():
|
||||
req_path = path.lstrip("/")
|
||||
if req_path.endswith("/info/refs"):
|
||||
repo_name = req_path[: -len("/info/refs")]
|
||||
elif req_path.endswith("/git-upload-pack"):
|
||||
repo_name = req_path[: -len("/git-upload-pack")]
|
||||
elif req_path.endswith("/git-receive-pack"):
|
||||
repo_name = req_path[: -len("/git-receive-pack")]
|
||||
else:
|
||||
repo_name = req_path
|
||||
if repo_name.endswith(".git"):
|
||||
repo_name = repo_name[:-4]
|
||||
repo_name = repo_name[4:]
|
||||
repo_dir = repository_path.joinpath(repo_name + ".git")
|
||||
logger.info(f"Resolved repo path: {repo_dir}")
|
||||
logger.info(
|
||||
f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
|
||||
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
|
||||
)
|
||||
return repo_dir
|
||||
|
||||
async def handle_info_refs(service):
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -66,11 +66,13 @@ header .logo {
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: #aaa;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
margin-left: 15px;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
@ -82,6 +84,7 @@ header nav a {
|
||||
|
||||
header nav a:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
a {
|
||||
@ -368,7 +371,7 @@ input[type="text"], .chat-input textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.message.switch-user + .message, .message.long-time + .message, .message:first-child {
|
||||
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
|
||||
.time {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
@ -407,34 +410,55 @@ a {
|
||||
width: 250px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 10px;
|
||||
padding-top: 20px;
|
||||
overflow-y: auto;
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
color: #f05a28;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sidebar h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
color: #ccc;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
transition: color 0.3s;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
color: #fff;
|
||||
color: #e6e6e6;
|
||||
background-color: #1a1a1a;
|
||||
border-left-color: #444;
|
||||
}
|
||||
|
||||
.sidebar ul li a.active {
|
||||
color: #f05a28;
|
||||
border-left-color: #f05a28;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
@ -549,12 +573,22 @@ dialog .dialog-actions {
|
||||
}
|
||||
|
||||
dialog .dialog-button {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
dialog .dialog-button:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@ -580,38 +614,40 @@ dialog .dialog-button {
|
||||
|
||||
dialog .dialog-button.primary {
|
||||
background-color: #f05a28;
|
||||
color: white;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary:hover {
|
||||
background-color: #f05a28;
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary {
|
||||
background-color: #f0a328;
|
||||
color: #eee;
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary:hover {
|
||||
background-color: #f0b84c;
|
||||
background-color: #3a3a3a;
|
||||
border-color: #555;
|
||||
}
|
||||
dialog .dialog-button.primary:disabled,
|
||||
dialog .dialog-button.primary[aria-disabled="true"] {
|
||||
/* slightly darker + lower saturation of the live colour */
|
||||
background-color: #70321e; /* muted burnt orange */
|
||||
color: #bfbfbf; /* light grey text */
|
||||
|
||||
opacity: .55; /* unified fade */
|
||||
background-color: #0a0a0a;
|
||||
border-color: #222;
|
||||
color: #555;
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- SECONDARY (yellow) ---------- */
|
||||
dialog .dialog-button.secondary:disabled,
|
||||
dialog .dialog-button.secondary[aria-disabled="true"] {
|
||||
background-color: #6c5619; /* muted mustard */
|
||||
color: #bfbfbf;
|
||||
|
||||
background-color: #0a0a0a;
|
||||
border-color: #222;
|
||||
color: #555;
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
93
src/snek/static/buttons.css
Normal file
93
src/snek/static/buttons.css
Normal file
@ -0,0 +1,93 @@
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
background: #111;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #0a0a0a;
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #1a1a1a;
|
||||
border-color: #8b0000;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #2a1515;
|
||||
border-color: #b00;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #1a1a1a;
|
||||
border-color: #006400;
|
||||
color: #6bff6b;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #152a15;
|
||||
border-color: #0b0;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
@ -81,7 +81,152 @@ 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;
|
||||
@ -100,53 +245,14 @@ 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 = {
|
||||
@ -392,7 +498,7 @@ textToLeetAdvanced(text) {
|
||||
}
|
||||
j++;
|
||||
}
|
||||
return i === s.length;
|
||||
return i === s.length && s.length > 1;
|
||||
}
|
||||
|
||||
flagTyping() {
|
||||
|
||||
@ -1,226 +1,494 @@
|
||||
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 = `
|
||||
#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;
|
||||
}
|
||||
`;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
this.editor = document.createElement('div');
|
||||
this.editor.id = 'editor';
|
||||
this.editor.contentEditable = true;
|
||||
this.editor.innerText = `Welcome to VimEditor Component
|
||||
this.editor = document.createElement('div');
|
||||
this.editor.id = 'editor';
|
||||
this.editor.contentEditable = true;
|
||||
this.editor.spellcheck = false;
|
||||
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';
|
||||
this.shadowRoot.append(style, this.editor, this.cmdLine);
|
||||
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.mode = 'normal'; // normal | insert | visual | command
|
||||
this.keyBuffer = '';
|
||||
this.lastDeletedLine = '';
|
||||
this.yankedLine = '';
|
||||
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.editor.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||
}
|
||||
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
|
||||
|
||||
connectedCallback() {
|
||||
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
|
||||
this.editor.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCaretOffset() {
|
||||
let caretOffset = 0;
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
updateVisualSelection() {
|
||||
if (this.mode !== 'visual') return;
|
||||
this.visualEndOffset = this.getCaretOffset();
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setCaretOffset(offset) {
|
||||
const range = document.createRange();
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
handleBeforeInput(e) {
|
||||
if (this.mode !== 'insert') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
currentOffset += node.length;
|
||||
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;
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
const key = e.key;
|
||||
if (this.mode === 'command') {
|
||||
return; // Command mode input is handled by cmdInput
|
||||
}
|
||||
|
||||
if (this.mode === 'insert') {
|
||||
if (key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.mode = 'normal';
|
||||
this.editor.blur();
|
||||
this.editor.focus();
|
||||
}
|
||||
return;
|
||||
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
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.yankedLine = sel.toString();
|
||||
}
|
||||
|
||||
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;
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'd' || e.key === 'x') {
|
||||
e.preventDefault();
|
||||
// Delete selected text
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.lastDeletedLine = sel.toString();
|
||||
document.execCommand('delete');
|
||||
}
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('njet-editor', NjetEditor);
|
||||
export {NjetEditor}
|
||||
// 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;
|
||||
}
|
||||
const prevLineLength = lines[lineIndex - 1].length;
|
||||
const newPosition = Math.min(positionInLine, prevLineLength);
|
||||
this.setCaretOffset(prevLineStart + newPosition);
|
||||
}
|
||||
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 = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('njet-editor', NjetEditor);
|
||||
export { NjetEditor }
|
||||
|
||||
@ -3,8 +3,15 @@ export class EventHandler {
|
||||
this.subscribers = {};
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
addEventListener(type, handler, { once = false } = {}) {
|
||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
handler = (...args) => {
|
||||
originalHandler(...args);
|
||||
this.removeEventListener(type, handler);
|
||||
};
|
||||
}
|
||||
this.subscribers[type].push(handler);
|
||||
}
|
||||
|
||||
@ -12,4 +19,15 @@ 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,21 +28,30 @@ class FancyButton extends HTMLElement {
|
||||
button {
|
||||
width: var(--width);
|
||||
min-width: ${size};
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #f05a28;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
button:hover {
|
||||
color: #EFEFEF;
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border: 1px solid #efefef;
|
||||
border-color: #e04924;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #0a0a0a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
@ -15,61 +15,72 @@
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
generic-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: #000000;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
||||
input {
|
||||
|
||||
border: 10px solid #000000;
|
||||
}
|
||||
.generic-form-container generic-field {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.generic-form-container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generic-form-container button:hover {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.generic-form-container button[type="submit"],
|
||||
.generic-form-container button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.generic-form-container button[type="submit"]:hover,
|
||||
.generic-form-container button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
.generic-form-container a {
|
||||
@ -85,15 +96,13 @@ input {
|
||||
color: #e04924;
|
||||
}
|
||||
|
||||
|
||||
.error {
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.generic-form-container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,47 +76,66 @@ class GenericField extends HTMLElement {
|
||||
|
||||
input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #f05a28 !important;
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: #555;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
width: 50%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button[value="submit"], button.primary {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button[value="submit"]:hover, button.primary:hover {
|
||||
background-color: #e04924;
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -133,17 +152,13 @@ class GenericField extends HTMLElement {
|
||||
}
|
||||
|
||||
.valid {
|
||||
border: 1px solid green;
|
||||
color: green;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #006400;
|
||||
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 3px solid red;
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
|
||||
102
src/snek/static/inputs.css
Normal file
102
src/snek/static/inputs.css
Normal file
@ -0,0 +1,102 @@
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
background: #0a0a0a;
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.input-success {
|
||||
border-color: #006400;
|
||||
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: #f05a28;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #f05a28;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
100
src/snek/static/lists.css
Normal file
100
src/snek/static/lists.css
Normal file
@ -0,0 +1,100 @@
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.settings-list-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.settings-list-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-list-title i {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.settings-list-meta {
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-list-meta code {
|
||||
background: #0f0f0f;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.settings-list-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.settings-list-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-topbar h2 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.settings-empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.settings-empty p {
|
||||
color: #888;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings-list-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-list-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@ -5,30 +5,71 @@
|
||||
// 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;
|
||||
|
||||
// Clone and sanitize message node to text-only reply
|
||||
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());
|
||||
|
||||
// Replace <picture> with their <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));
|
||||
});
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
// Replace <iframe> with their src
|
||||
newMessage.querySelectorAll('iframe').forEach(iframe => {
|
||||
const src = iframe.src || iframe.currentSrc;
|
||||
iframe.replaceWith(document.createTextNode(src));
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@ -38,23 +79,30 @@ 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></div>
|
||||
<a href="#reply">reply</a>
|
||||
</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));
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.siblingGenerated && this.nextElementSibling) {
|
||||
this.siblingGenerated = true;
|
||||
// Sibling logic for user switches and long time gaps
|
||||
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
|
||||
this.siblingGenerated = this.nextElementSibling;
|
||||
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
|
||||
this.classList.add('switch-user');
|
||||
} else {
|
||||
@ -62,7 +110,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');
|
||||
@ -75,7 +123,7 @@ class MessageElement extends HTMLElement {
|
||||
|
||||
updateMessage(...messages) {
|
||||
if (this._originalChildren) {
|
||||
this.messageDiv.replaceChildren(...messages)
|
||||
this.messageDiv.replaceChildren(...messages);
|
||||
this._originalChildren = messages;
|
||||
}
|
||||
}
|
||||
@ -84,62 +132,70 @@ 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) => {
|
||||
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);
|
||||
const messageElement = entry.target;
|
||||
if (messageElement instanceof MessageElement) {
|
||||
messageElement.updateUI();
|
||||
}
|
||||
} else {
|
||||
this.visibleSet.delete(entry.target);
|
||||
}
|
||||
});
|
||||
console.log(this.visibleSet);
|
||||
}, {
|
||||
root: this,
|
||||
threshold: 0.1
|
||||
})
|
||||
|
||||
for(const c of this.children) {
|
||||
this._observer.observe(c);
|
||||
if (c instanceof MessageElement) {
|
||||
this.messageMap.set(c.dataset.uid, c);
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
this.visibleSet.add(entry.target);
|
||||
if (entry.target instanceof MessageElement) {
|
||||
entry.target.updateUI();
|
||||
}
|
||||
} else {
|
||||
this.visibleSet.delete(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
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) {
|
||||
this._observer.observe(c);
|
||||
if (c instanceof MessageElement) {
|
||||
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.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;';
|
||||
|
||||
@ -160,12 +216,11 @@ 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);
|
||||
});
|
||||
// Optional: ESC key closes overlay
|
||||
// ESC to close
|
||||
const escListener = (evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
@ -173,9 +228,9 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escListener);
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
isElementVisible(element) {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
@ -186,14 +241,16 @@ class MessageList extends HTMLElement {
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
isScrolledToBottom() {
|
||||
return this.isElementVisible(this.firstElementChild);
|
||||
return this.visibleSet.has(this.endOfMessages);
|
||||
}
|
||||
scrollToBottom(force = false, behavior= 'smooth') {
|
||||
if (force || this.isScrolledToBottom()) {
|
||||
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||
|
||||
scrollToBottom(force = false, behavior = 'instant') {
|
||||
if (force || !this.isScrolledToBottom()) {
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
setTimeout(() => {
|
||||
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
@ -205,7 +262,9 @@ class MessageList extends HTMLElement {
|
||||
this.querySelectorAll('.avatar').forEach((el) => {
|
||||
const anchor = el.closest('a');
|
||||
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
|
||||
lastElement = el;
|
||||
if(!lastElement)
|
||||
lastElement = el;
|
||||
|
||||
}
|
||||
});
|
||||
if (lastElement) {
|
||||
@ -216,41 +275,48 @@ 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);
|
||||
const newMessage = !!message;
|
||||
if (message) {
|
||||
message.parentElement.removeChild(message);
|
||||
if (message && (data.is_final || !data.message)) {
|
||||
//message.parentElement?.removeChild(message);
|
||||
// TO force insert
|
||||
//message = null;
|
||||
|
||||
}
|
||||
|
||||
if (!data.message) return
|
||||
if(message && !data.message){
|
||||
message.parentElement?.removeChild(message);
|
||||
message = null;
|
||||
}
|
||||
if (!data.message) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
wrapper.innerHTML = data.html;
|
||||
|
||||
if (message) {
|
||||
message.updateMessage(...wrapper.firstElementChild._originalChildren);
|
||||
// If the old element is already custom, only update its message children
|
||||
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
|
||||
} else {
|
||||
message = wrapper.firstElementChild;
|
||||
this.messageMap.set(data.uid, message);
|
||||
this._observer.observe(message);
|
||||
// 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, !newMessage ? 'smooth' : 'auto');
|
||||
|
||||
if (scrolledToBottom) this.scrollToBottom(true);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-message", MessageElement);
|
||||
customElements.define("message-list", MessageList);
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
class RestClient {
|
||||
constructor({ baseURL = '', headers = {} } = {}) {
|
||||
this.baseURL = baseURL;
|
||||
@ -210,27 +208,52 @@ class Njet extends HTMLElement {
|
||||
customElements.define(name, component);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
constructor(config) {
|
||||
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);
|
||||
//this.initProps(config);
|
||||
//if (typeof this.config.construct === 'function')
|
||||
// this.config.construct.call(this)
|
||||
|
||||
// Call construct if defined
|
||||
if (typeof this.config.construct === 'function') {
|
||||
this.config.construct.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
initProps(config) {
|
||||
const props = Object.keys(config)
|
||||
props.forEach(prop => {
|
||||
if (config[prop] !== undefined) {
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
@ -342,7 +365,7 @@ class NjetDialog extends Component {
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.marginTop = '20px';
|
||||
buttonContainer.style.display = 'flex';
|
||||
buttonContainer.style.justifyContent = 'flenjet-end';
|
||||
buttonContainer.style.justifyContent = 'flex-end';
|
||||
buttonContainer.style.gap = '10px';
|
||||
if (secondaryButton) {
|
||||
const secondary = new NjetButton(secondaryButton);
|
||||
@ -372,8 +395,9 @@ class NjetWindow extends Component {
|
||||
header.textContent = title;
|
||||
this.appendChild(header);
|
||||
}
|
||||
this.config.items.forEach(item => this.appendChild(item));
|
||||
|
||||
if (this.config.items) {
|
||||
this.config.items.forEach(item => this.appendChild(item));
|
||||
}
|
||||
}
|
||||
|
||||
show(){
|
||||
@ -408,7 +432,8 @@ class NjetGrid extends Component {
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-grid', NjetGrid);
|
||||
/*
|
||||
|
||||
/* Example usage:
|
||||
const button = new NjetButton({
|
||||
classes: ['my-button'],
|
||||
text: 'Shared',
|
||||
@ -493,7 +518,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()
|
||||
@ -545,15 +570,16 @@ njet.showWindow = function(args) {
|
||||
return w
|
||||
}
|
||||
njet.publish = function(event, data) {
|
||||
if (this.root._subscriptions[event]) {
|
||||
if (this.root && this.root._subscriptions && 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 };
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.registration-container {
|
||||
}
|
||||
|
||||
.registration-container {
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
@ -15,63 +12,90 @@
|
||||
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;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
background-color: #0f0f0f;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.registration-container button {
|
||||
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;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.registration-container button:hover {
|
||||
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 {
|
||||
background-color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.registration-container button[type="submit"]:hover,
|
||||
.registration-container button.primary:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
.registration-container a {
|
||||
border-color: #e04924;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: #8b0000;
|
||||
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.registration-container {
|
||||
width: 90%;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -86,6 +86,7 @@ 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);
|
||||
}
|
||||
}
|
||||
@ -99,7 +100,7 @@ export class Socket extends EventHandler {
|
||||
console.log("Reconnecting");
|
||||
this.emit("reconnecting");
|
||||
return this.connect();
|
||||
}, 0);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
_camelToSnake(str) {
|
||||
@ -142,10 +143,9 @@ export class Socket extends EventHandler {
|
||||
method,
|
||||
args,
|
||||
};
|
||||
const me = this;
|
||||
return new Promise((resolve) => {
|
||||
me.addEventListener(call.callId, (data) => resolve(data));
|
||||
me.sendJson(call);
|
||||
this.addEventListener(call.callId, (data) => resolve(data), { once: true});
|
||||
this.sendJson(call);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/snek/static/webfonts/fa-brands-400.woff2
Normal file
BIN
src/snek/static/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
src/snek/static/webfonts/fa-regular-400.woff2
Normal file
BIN
src/snek/static/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
src/snek/static/webfonts/fa-solid-900.woff2
Normal file
BIN
src/snek/static/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
src/snek/static/webfonts/fa-v4compatibility.woff2
Normal file
BIN
src/snek/static/webfonts/fa-v4compatibility.woff2
Normal file
Binary file not shown.
117
src/snek/sync.py
117
src/snek/sync.py
@ -1,135 +1,22 @@
|
||||
|
||||
|
||||
|
||||
|
||||
class DatasetWebSocketView:
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
self.db = dataset.connect('sqlite:///snek.db')
|
||||
self.setattr(self, "db", self.get)
|
||||
self.setattr(self, "db", self.set)
|
||||
)
|
||||
setattr(self, "db", self.get)
|
||||
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)
|
||||
|
||||
|
||||
@ -1,75 +1,137 @@
|
||||
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
|
||||
self.cache = {}
|
||||
# OrderedDict is the core of the LRU logic. It remembers the order
|
||||
# in which items were inserted.
|
||||
self.cache: OrderedDict = OrderedDict()
|
||||
self.max_items = max_items
|
||||
self.stats = {}
|
||||
self.enabled = True
|
||||
self.lru = []
|
||||
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
|
||||
# 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
|
||||
|
||||
async def get(self, args):
|
||||
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.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
await self.update_stat(args, "get")
|
||||
try:
|
||||
self.lru.pop(self.lru.index(args))
|
||||
except:
|
||||
# print("Cache miss!", args, flush=True)
|
||||
|
||||
#async with self._lock:
|
||||
if key not in self.cache:
|
||||
await self.update_stat(key, "get")
|
||||
return None
|
||||
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]
|
||||
|
||||
# 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]
|
||||
|
||||
async def get_stats(self):
|
||||
all_ = []
|
||||
for key in self.lru:
|
||||
all_.append(
|
||||
{
|
||||
"key": key,
|
||||
"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_
|
||||
"""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
|
||||
|
||||
def serialize(self, obj):
|
||||
cpy = obj.copy()
|
||||
cpy.pop("created_at", None)
|
||||
cpy.pop("deleted_at", None)
|
||||
cpy.pop("email", None)
|
||||
cpy.pop("password", None)
|
||||
return cpy
|
||||
stats_list.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):
|
||||
if key not in self.stats:
|
||||
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
|
||||
self.stats[key][action] = self.stats[key][action] + 1
|
||||
"""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
|
||||
|
||||
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)
|
||||
return cpy
|
||||
|
||||
def json_default(self, value):
|
||||
# if hasattr(value, "to_json"):
|
||||
# return value.to_json()
|
||||
"""JSON serializer fallback for objects that are not directly serializable."""
|
||||
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},
|
||||
@ -78,38 +140,8 @@ 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)
|
||||
@ -119,33 +151,14 @@ 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)
|
||||
if cache_key in self.cache:
|
||||
try:
|
||||
self.lru.pop(self.lru.index(cache_key))
|
||||
except IndexError:
|
||||
pass
|
||||
del self.cache[cache_key]
|
||||
await self.delete(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
|
||||
|
||||
108
src/snek/system/debug.py
Normal file
108
src/snek/system/debug.py
Normal file
@ -0,0 +1,108 @@
|
||||
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
|
||||
55
src/snek/system/exception_middleware.py
Normal file
55
src/snek/system/exception_middleware.py
Normal file
@ -0,0 +1,55 @@
|
||||
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
|
||||
)
|
||||
20
src/snek/system/exceptions.py
Normal file
20
src/snek/system/exceptions.py
Normal file
@ -0,0 +1,20 @@
|
||||
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
|
||||
@ -1,7 +1,7 @@
|
||||
DEFAULT_LIMIT = 30
|
||||
import asyncio
|
||||
import typing
|
||||
|
||||
import time
|
||||
from snek.system.model import BaseModel
|
||||
|
||||
|
||||
@ -15,8 +15,6 @@ class BaseMapper:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
self.default_limit = self.__class__.default_limit
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.app.db
|
||||
@ -27,6 +25,7 @@ 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)
|
||||
@ -34,10 +33,8 @@ class BaseMapper:
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
return _execute()
|
||||
#async with self.semaphore:
|
||||
# return await self.loop.run_in_executor(None, _execute)
|
||||
|
||||
async with self.semaphore:
|
||||
return await asyncio.to_thread(_execute)
|
||||
|
||||
async def new(self):
|
||||
return self.model_class(mapper=self, app=self.app)
|
||||
|
||||
@ -146,7 +146,10 @@ class Validator:
|
||||
return True
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.to_json())
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
|
||||
@ -79,44 +79,38 @@ emoji.EMOJI_DATA[
|
||||
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
||||
|
||||
|
||||
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": [],
|
||||
}
|
||||
|
||||
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["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,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
|
||||
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
|
||||
strip=True,
|
||||
)
|
||||
|
||||
@ -132,50 +126,8 @@ 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):
|
||||
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)
|
||||
return sanitize_html(html)
|
||||
|
||||
|
||||
def embed_youtube(text):
|
||||
|
||||
@ -30,9 +30,12 @@
|
||||
<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"></script>
|
||||
<script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -42,7 +45,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="/threads.html">👥</a>
|
||||
<a class="no-select" href="/forum/index.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>
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
<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>
|
||||
|
||||
@ -117,6 +117,13 @@ 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';
|
||||
@ -695,9 +702,11 @@ class SnekForum extends HTMLElement {
|
||||
}
|
||||
|
||||
updateBreadcrumb() {
|
||||
return;
|
||||
const breadcrumbContainer = document.getElementById('breadcrumb');
|
||||
if (!breadcrumbContainer) return;
|
||||
|
||||
const crumb = [];
|
||||
crumb.push(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
|
||||
crumb.push(`<a href="#" data-action="forums">FORUMS</a>`);
|
||||
if (this.currentView === "forum" && this.currentForum) {
|
||||
crumb.push(`<span>›</span>`);
|
||||
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
|
||||
@ -708,7 +717,7 @@ class SnekForum extends HTMLElement {
|
||||
crumb.push(`<span>›</span>`);
|
||||
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
|
||||
}
|
||||
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
|
||||
breadcrumbContainer.innerHTML = crumb.join(' ');
|
||||
}
|
||||
|
||||
renderForums(forums) {
|
||||
@ -905,6 +914,9 @@ class SnekForum extends HTMLElement {
|
||||
}
|
||||
customElements.define('snek-forum', SnekForum);
|
||||
</script>
|
||||
<script>
|
||||
window.preloadedForums = {{ forums_json|safe }};
|
||||
</script>
|
||||
<snek-forum></snek-forum>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -63,16 +63,34 @@
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .75rem 1.5rem;
|
||||
padding: 10px 20px;
|
||||
margin: .5rem;
|
||||
background: #0fa;
|
||||
color: #111;
|
||||
font-weight: bold;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
color: #e6e6e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
transition: background .2s;
|
||||
transition: all .2s ease;
|
||||
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));
|
||||
@ -149,7 +167,7 @@
|
||||
<h1>Snek</h1>
|
||||
<p>Professional Platform for Developers, Testers & AI Professionals</p>
|
||||
<a href="/login.html" class="btn">Login</a>
|
||||
<a href="/register.html" class="btn">Register</a>
|
||||
<a href="/register.html" class="btn btn-primary">Register</a>
|
||||
<a href="/about.html" class="about-link">About</a>
|
||||
</header>
|
||||
<main class="container">
|
||||
@ -212,7 +230,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">Sign Up</a>
|
||||
<a href="/register.html" class="btn btn-primary">Sign Up</a>
|
||||
<br>
|
||||
<a href="/about.html" class="about-link">Learn more about Snek</a>
|
||||
</section>
|
||||
|
||||
97
src/snek/templates/new.html
Normal file
97
src/snek/templates/new.html
Normal file
@ -0,0 +1,97 @@
|
||||
<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>
|
||||
35
src/snek/templates/profile_page.html
Normal file
35
src/snek/templates/profile_page.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% 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 %}
|
||||
26
src/snek/templates/repository_empty.html
Normal file
26
src/snek/templates/repository_empty.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% 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 %}
|
||||
46
src/snek/templates/repository_file.html
Normal file
46
src/snek/templates/repository_file.html
Normal file
@ -0,0 +1,46 @@
|
||||
{% 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 %}
|
||||
59
src/snek/templates/repository_overview.html
Normal file
59
src/snek/templates/repository_overview.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% 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 %}
|
||||
56
src/snek/templates/repository_tree.html
Normal file
56
src/snek/templates/repository_tree.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% 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 %}
|
||||
@ -12,7 +12,7 @@ function showTerm(options){
|
||||
|
||||
|
||||
class StarField {
|
||||
constructor({ count = 200, container = document.body } = {}) {
|
||||
constructor({ count = 50, 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: 200});
|
||||
const starField = new StarField({starCount: 50});
|
||||
app.starField = starField;
|
||||
|
||||
class DemoSequence {
|
||||
|
||||
@ -1,28 +1,88 @@
|
||||
<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-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>
|
||||
|
||||
|
||||
}
|
||||
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>
|
||||
|
||||
@ -49,16 +49,71 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.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>
|
||||
|
||||
@ -8,9 +8,7 @@
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@ -3,35 +3,55 @@
|
||||
{% block header_text %}<h2 style="color:#fff">Profile</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<section>
|
||||
<section style="padding: 20px;">
|
||||
<form method="post">
|
||||
<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">Nickname</label>
|
||||
<input type="text" name="nick" placeholder="Your nickname" value="{{ user.nick.value }}" class="input" />
|
||||
</div>
|
||||
|
||||
<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")});
|
||||
</script>
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
easyMDE.codemirror.save();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
|
||||
.EasyMDEContainer {
|
||||
filter: invert(1) !important;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
127
src/snek/templates/settings/profile_pages/create.html
Normal file
127
src/snek/templates/settings/profile_pages/create.html
Normal file
@ -0,0 +1,127 @@
|
||||
{% 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 %}
|
||||
133
src/snek/templates/settings/profile_pages/edit.html
Normal file
133
src/snek/templates/settings/profile_pages/edit.html
Normal file
@ -0,0 +1,133 @@
|
||||
{% 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 %}
|
||||
55
src/snek/templates/settings/profile_pages/index.html
Normal file
55
src/snek/templates/settings/profile_pages/index.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% 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 %}
|
||||
@ -10,14 +10,18 @@
|
||||
<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-pen"></i> Update</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
<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>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,28 +1,88 @@
|
||||
<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-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>
|
||||
|
||||
|
||||
}
|
||||
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>
|
||||
|
||||
@ -1,106 +1,59 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-database"></i> Repositories</h1>{% endblock %}
|
||||
{% block header_text %}<h2 style="color:#fff">Repositories</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<!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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{% 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>
|
||||
</div>
|
||||
<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>
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
@ -6,12 +6,15 @@
|
||||
{% 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 %}>
|
||||
@ -19,7 +22,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>You</h2>
|
||||
<aside class="sidebar settings-sidebar" id="channelSidebar">
|
||||
<h2>Settings</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>
|
||||
|
||||
@ -12,7 +12,15 @@
|
||||
<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>
|
||||
<h2>Gists</h2>
|
||||
{% 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>
|
||||
<ul>
|
||||
<li>No gists</li>
|
||||
</ul>
|
||||
|
||||
@ -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,11 +8,14 @@
|
||||
<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>
|
||||
@ -37,10 +40,9 @@
|
||||
{% include "dialog_help.html" %}
|
||||
{% include "dialog_online.html" %}
|
||||
<script type="module">
|
||||
import { app } from "/app.js";
|
||||
import { Schedule } from "/schedule.js";
|
||||
import {app} from "/app.js";
|
||||
|
||||
// --- Cache selectors ---
|
||||
// --- Cache selectors ---
|
||||
const chatInputField = document.querySelector("chat-input");
|
||||
const messagesContainer = document.querySelector(".chat-messages");
|
||||
const chatArea = document.querySelector(".chat-area");
|
||||
@ -52,7 +54,7 @@ chatInputField.autoCompletions = {
|
||||
"/clear": () => { messagesContainer.innerHTML = ''; },
|
||||
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
||||
"/help": showHelp,
|
||||
"/container": async() =>{
|
||||
"/container": async() =>{
|
||||
containerDialog.openWithStatus()
|
||||
}
|
||||
};
|
||||
@ -72,12 +74,13 @@ function throttle(fn, wait) {
|
||||
// --- Scroll: load extra messages, throttled ---
|
||||
let isLoadingExtra = false;
|
||||
async function loadExtra() {
|
||||
const firstMessage = messagesContainer.children[messagesContainer.children.length - 1];
|
||||
const firstMessage = messagesContainer.lastElementChild;
|
||||
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
||||
isLoadingExtra = true;
|
||||
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
||||
if (messages.length) {
|
||||
const frag = document.createDocumentFragment();
|
||||
messages.reverse();
|
||||
messages.forEach(msg => {
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = msg.html;
|
||||
@ -98,21 +101,61 @@ setInterval(() => requestIdleCallback(updateTimes), 30000);
|
||||
|
||||
// --- Paste & drag/drop uploads ---
|
||||
const textBox = chatInputField.textarea;
|
||||
textBox.addEventListener("paste", async (e) => {
|
||||
try {
|
||||
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 }));
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dt.items.length > 0) {
|
||||
const uploadButton = chatInputField.uploadButton;
|
||||
const input = uploadButton.shadowRoot.querySelector('.file-input');
|
||||
input.files = dt.files;
|
||||
await uploadButton.uploadFiles();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadDataTransfer(e.clipboardData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read clipboard contents: ", error);
|
||||
@ -120,13 +163,7 @@ textBox.addEventListener("paste", async (e) => {
|
||||
});
|
||||
chatArea.addEventListener("drop", async (e) => {
|
||||
e.preventDefault();
|
||||
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();
|
||||
}
|
||||
uploadDataTransfer(e.dataTransfer);
|
||||
});
|
||||
chatArea.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
@ -138,10 +175,16 @@ chatInputField.textarea.focus();
|
||||
|
||||
// --- Reply helper ---
|
||||
function replyMessage(message) {
|
||||
chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n";
|
||||
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
|
||||
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
chatInputField.focus();
|
||||
}
|
||||
|
||||
messagesContainer.addEventListener("reply", (e) => {
|
||||
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
|
||||
replyMessage(messageText);
|
||||
})
|
||||
|
||||
// --- Mention helpers ---
|
||||
function extractMentions(message) {
|
||||
return [...new Set(message.match(/@\w+/g) || [])];
|
||||
@ -173,7 +216,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) {
|
||||
@ -214,8 +257,8 @@ document.addEventListener('keydown', function(event) {
|
||||
clearTimeout(keyTimeout);
|
||||
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
||||
if (gPressCount === 2) {
|
||||
gPressCount = 0;
|
||||
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||
gPressCount = 0;
|
||||
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||
loadExtra();
|
||||
}
|
||||
}
|
||||
@ -254,13 +297,82 @@ function updateLayout(doScrollDown) {
|
||||
function isScrolledPastHalf() {
|
||||
let scrollTop = messagesContainer.scrollTop;
|
||||
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
||||
return scrollTop < scrollableHeight / 2;
|
||||
return Math.abs(scrollTop) > scrollableHeight / 2;
|
||||
}
|
||||
|
||||
// --- 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 %}
|
||||
|
||||
@ -71,7 +71,10 @@ class AvatarView(BaseView):
|
||||
uid = self.request.match_info.get("uid")
|
||||
if uid == "unique":
|
||||
uid = str(uuid.uuid4())
|
||||
avatar = multiavatar.multiavatar(uid, True, None)
|
||||
avatar = await self.app.get(uid)
|
||||
if not avatar:
|
||||
avatar = multiavatar.multiavatar(uid, True, None)
|
||||
await self.app.set(uid, avatar)
|
||||
response = web.Response(text=avatar, content_type="image/svg+xml")
|
||||
response.headers["Cache-Control"] = f"public, max-age={1337*42}"
|
||||
return response
|
||||
|
||||
@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
|
||||
|
||||
class ChannelAttachmentView(BaseView):
|
||||
|
||||
login_required=True
|
||||
login_required=False
|
||||
|
||||
async def get(self):
|
||||
relative_path = self.request.match_info.get("relative_url")
|
||||
|
||||
@ -17,57 +17,27 @@ class ForumIndexView(BaseView):
|
||||
async def get(self):
|
||||
if self.login_required and not self.session.get("logged_in"):
|
||||
return web.HTTPFound("/")
|
||||
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()
|
||||
|
||||
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"]
|
||||
})
|
||||
|
||||
return await self.render_template(
|
||||
"forum.html",
|
||||
{"name": name, "channel": channel, "user": user, "messages": messages},
|
||||
{
|
||||
"forums_json": json.dumps(forums),
|
||||
"user": await self.services.user.get(self.session.get("uid"))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -80,11 +50,9 @@ 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.services.forum.get_active_forums():
|
||||
async for forum in self.app.services.forum.get_active_forums():
|
||||
forums.append({
|
||||
"uid": forum["uid"],
|
||||
"name": forum["name"],
|
||||
@ -99,28 +67,25 @@ 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.request.match_info["slug"]
|
||||
forum = await self.services.forum.get(slug=slug, is_active=True)
|
||||
slug = self.match_info["slug"]
|
||||
forum = await self.app.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.request.query.get("page", 1))
|
||||
page = int(self.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.services.user.get(uid=thread["created_by_uid"])
|
||||
author = await self.app.services.user.get(uid=thread["created_by_uid"])
|
||||
last_post_author = None
|
||||
if thread["last_post_by_uid"]:
|
||||
last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
|
||||
last_post_author = await self.app.services.user.get(uid=thread["last_post_by_uid"])
|
||||
|
||||
threads.append({
|
||||
"uid": thread["uid"],
|
||||
@ -162,21 +127,17 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
slug = self.request.match_info["slug"]
|
||||
forum = await self.services.forum.get(slug=slug, is_active=True)
|
||||
slug = self.match_info["slug"]
|
||||
forum = await self.app.services.forum.get(slug=slug, is_active=True)
|
||||
|
||||
if not forum:
|
||||
return web.json_response({"error": "Forum not found"}, status=404)
|
||||
|
||||
data = await self.request.json()
|
||||
data = await self.json()
|
||||
title = data.get("title", "").strip()
|
||||
content = data.get("content", "").strip()
|
||||
|
||||
@ -184,11 +145,11 @@ class ForumView(BaseView):
|
||||
return web.json_response({"error": "Title and content required"}, status=400)
|
||||
|
||||
try:
|
||||
thread, post = await self.services.thread.create_thread(
|
||||
thread, post = await self.app.services.thread.create_thread(
|
||||
forum_uid=forum["uid"],
|
||||
title=title,
|
||||
content=content,
|
||||
created_by_uid=self.request.session["uid"]
|
||||
created_by_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
@ -202,13 +163,9 @@ 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.request.match_info["thread_slug"]
|
||||
thread = await self.services.thread.get(slug=thread_slug)
|
||||
thread_slug = self.match_info["thread_slug"]
|
||||
thread = await self.app.services.thread.get(slug=thread_slug)
|
||||
|
||||
if not thread:
|
||||
return web.json_response({"error": "Thread not found"}, status=404)
|
||||
@ -217,15 +174,15 @@ class ForumView(BaseView):
|
||||
await thread.increment_view_count()
|
||||
|
||||
# Get forum
|
||||
forum = await self.services.forum.get(uid=thread["forum_uid"])
|
||||
forum = await self.app.services.forum.get(uid=thread["forum_uid"])
|
||||
|
||||
# Get posts
|
||||
posts = []
|
||||
page = int(self.request.query.get("page", 1))
|
||||
page = int(self.query.get("page", 1))
|
||||
limit = 50
|
||||
offset = (page - 1) * limit
|
||||
|
||||
current_user_uid = self.request.session.get("uid")
|
||||
current_user_uid = self.session.get("uid")
|
||||
|
||||
async for post in thread.get_posts(limit=limit, offset=offset):
|
||||
author = await post.get_author()
|
||||
@ -250,7 +207,7 @@ class ForumView(BaseView):
|
||||
})
|
||||
|
||||
# Get thread author
|
||||
thread_author = await self.services.user.get(uid=thread["created_by_uid"])
|
||||
thread_author = await self.app.services.user.get(uid=thread["created_by_uid"])
|
||||
|
||||
return web.json_response({
|
||||
"thread": {
|
||||
@ -280,32 +237,28 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
thread_uid = self.request.match_info["thread_uid"]
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
thread_uid = self.match_info["thread_uid"]
|
||||
thread = await self.app.services.thread.get(uid=thread_uid)
|
||||
|
||||
if not thread:
|
||||
return web.json_response({"error": "Thread not found"}, status=404)
|
||||
|
||||
data = await self.request.json()
|
||||
data = await self.json()
|
||||
content = data.get("content", "").strip()
|
||||
|
||||
if not content:
|
||||
return web.json_response({"error": "Content required"}, status=400)
|
||||
|
||||
try:
|
||||
post = await self.services.post.create_post(
|
||||
post = await self.app.services.post.create_post(
|
||||
thread_uid=thread["uid"],
|
||||
forum_uid=thread["forum_uid"],
|
||||
content=content,
|
||||
created_by_uid=self.request.session["uid"]
|
||||
created_by_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
author = await post.get_author()
|
||||
@ -329,25 +282,21 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
post_uid = self.request.match_info["post_uid"]
|
||||
data = await self.request.json()
|
||||
post_uid = self.match_info["post_uid"]
|
||||
data = await self.json()
|
||||
content = data.get("content", "").strip()
|
||||
|
||||
if not content:
|
||||
return web.json_response({"error": "Content required"}, status=400)
|
||||
|
||||
post = await self.services.post.edit_post(
|
||||
post = await self.app.services.post.edit_post(
|
||||
post_uid=post_uid,
|
||||
content=content,
|
||||
user_uid=self.request.session["uid"]
|
||||
user_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
if not post:
|
||||
@ -362,19 +311,15 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
post_uid = self.request.match_info["post_uid"]
|
||||
post_uid = self.match_info["post_uid"]
|
||||
|
||||
success = await self.services.post.delete_post(
|
||||
success = await self.app.services.post.delete_post(
|
||||
post_uid=post_uid,
|
||||
user_uid=self.request.session["uid"]
|
||||
user_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
if not success:
|
||||
@ -383,26 +328,22 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
post_uid = self.request.match_info["post_uid"]
|
||||
post_uid = self.match_info["post_uid"]
|
||||
|
||||
is_liked = await self.services.post_like.toggle_like(
|
||||
is_liked = await self.app.services.post_like.toggle_like(
|
||||
post_uid=post_uid,
|
||||
user_uid=self.request.session["uid"]
|
||||
user_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
if is_liked is None:
|
||||
return web.json_response({"error": "Failed to toggle like"}, status=400)
|
||||
|
||||
# Get updated post
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
post = await self.app.services.post.get(uid=post_uid)
|
||||
|
||||
return web.json_response({
|
||||
"is_liked": is_liked,
|
||||
@ -410,19 +351,15 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
thread_uid = self.request.match_info["thread_uid"]
|
||||
thread_uid = self.match_info["thread_uid"]
|
||||
|
||||
thread = await self.services.thread.toggle_pin(
|
||||
thread = await self.app.services.thread.toggle_pin(
|
||||
thread_uid=thread_uid,
|
||||
user_uid=self.request.session["uid"]
|
||||
user_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
if not thread:
|
||||
@ -431,19 +368,15 @@ 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.request.session.get("logged_in"):
|
||||
if not self.session.get("logged_in"):
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
thread_uid = self.request.match_info["thread_uid"]
|
||||
thread_uid = self.match_info["thread_uid"]
|
||||
|
||||
thread = await self.services.thread.toggle_lock(
|
||||
thread = await self.app.services.thread.toggle_lock(
|
||||
thread_uid=thread_uid,
|
||||
user_uid=self.request.session["uid"]
|
||||
user_uid=self.session["uid"]
|
||||
)
|
||||
|
||||
if not thread:
|
||||
|
||||
10
src/snek/view/git_docs.py
Normal file
10
src/snek/view/git_docs.py
Normal file
@ -0,0 +1,10 @@
|
||||
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")
|
||||
8
src/snek/view/new.py
Normal file
8
src/snek/view/new.py
Normal file
@ -0,0 +1,8 @@
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class NewView(BaseView):
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
return await self.render_template("new.html")
|
||||
45
src/snek/view/profile_page.py
Normal file
45
src/snek/view/profile_page.py
Normal file
@ -0,0 +1,45 @@
|
||||
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
|
||||
}
|
||||
)
|
||||
@ -1,189 +1,33 @@
|
||||
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.count("-") == 4:
|
||||
if not username or username.count("-") != 4:
|
||||
user = await self.app.services.user.get(username=username)
|
||||
if not user:
|
||||
return web.Response(text="404 Not Found", status=404)
|
||||
@ -191,99 +35,262 @@ 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)
|
||||
|
||||
repo_root_base = (base_repo_path / user["uid"] / (repo_name + ".git")).resolve()
|
||||
repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
|
||||
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)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None, self.checkout_bare_repo, repo_root_base, repo_root
|
||||
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),
|
||||
},
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
if not repo_root.exists() or not repo_root.is_dir():
|
||||
return web.Response(text="404 Not Found", status=404)
|
||||
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
|
||||
|
||||
safe_rel_path = os.path.normpath(rel_path).lstrip(os.sep)
|
||||
abs_path = (repo_root / safe_rel_path).resolve()
|
||||
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 = []
|
||||
|
||||
if not abs_path.exists() or not abs_path.is_relative_to(repo_root):
|
||||
return web.Response(text="404 Not Found", status=404)
|
||||
try:
|
||||
tree = current_commit.tree
|
||||
items = []
|
||||
|
||||
if abs_path.is_dir():
|
||||
return web.Response(
|
||||
text=self.render_directory(
|
||||
abs_path, username, repo_name, safe_rel_path
|
||||
),
|
||||
content_type="text/html",
|
||||
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,
|
||||
},
|
||||
)
|
||||
else:
|
||||
return web.Response(
|
||||
text=self.render_file(abs_path), content_type="text/html"
|
||||
)
|
||||
try:
|
||||
tree = current_commit.tree
|
||||
item = tree[rel_path]
|
||||
|
||||
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 = []
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
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>')
|
||||
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||
|
||||
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>')
|
||||
parent_path = str(Path(rel_path).parent) if rel_path != "." else ""
|
||||
if parent_path == ".":
|
||||
parent_path = ""
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
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>"
|
||||
try:
|
||||
text_content = content.decode("utf-8")
|
||||
is_binary = False
|
||||
except UnicodeDecodeError:
|
||||
text_content = None
|
||||
is_binary = True
|
||||
|
||||
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 "📦"
|
||||
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"
|
||||
|
||||
@ -6,12 +6,11 @@
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import random
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.model import now
|
||||
@ -176,6 +175,26 @@ 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 = []
|
||||
@ -305,7 +324,7 @@ class RPCView(BaseView):
|
||||
|
||||
async def send_message(self, channel_uid, message, is_final=True):
|
||||
self._require_login()
|
||||
|
||||
|
||||
message = message.strip()
|
||||
|
||||
if not is_final:
|
||||
@ -527,8 +546,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()
|
||||
@ -636,7 +655,7 @@ class RPCView(BaseView):
|
||||
try:
|
||||
await rpc(msg.json())
|
||||
except Exception as ex:
|
||||
print("Deleting socket", ex, flush=True)
|
||||
print("XXXXXXXXXX Deleting socket", ex, flush=True)
|
||||
logger.exception(ex)
|
||||
await self.services.socket.delete(ws)
|
||||
break
|
||||
|
||||
128
src/snek/view/settings/profile_pages.py
Normal file
128
src/snek/view/settings/profile_pages.py
Normal file
@ -0,0 +1,128 @@
|
||||
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})
|
||||
@ -36,6 +36,7 @@ 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")
|
||||
@ -61,6 +62,7 @@ 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")
|
||||
|
||||
@ -11,7 +11,16 @@ 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},
|
||||
{
|
||||
"user_uid": user_uid,
|
||||
"user": user.record,
|
||||
"profile": profile_content,
|
||||
"profile_pages": profile_pages
|
||||
},
|
||||
)
|
||||
|
||||
@ -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"]:
|
||||
if not channel["is_private"] and not channel.is_dm:
|
||||
channel_member = await self.app.services.channel_member.create(
|
||||
channel_uid=channel["uid"],
|
||||
user_uid=self.session.get("uid"),
|
||||
@ -82,7 +82,6 @@ class WebView(BaseView):
|
||||
await self.app.services.notification.mark_as_read(
|
||||
self.session.get("uid"), message["uid"]
|
||||
)
|
||||
print(messages)
|
||||
name = await channel_member.get_name()
|
||||
return await self.render_template(
|
||||
"web.html",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,78 +0,0 @@
|
||||
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}")
|
||||
@ -1,77 +0,0 @@
|
||||
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}")
|
||||
@ -1,74 +0,0 @@
|
||||
#!/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()
|
||||
@ -1,90 +0,0 @@
|
||||
#!/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()
|
||||
@ -1,112 +0,0 @@
|
||||
#!/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()
|
||||
Loading…
Reference in New Issue
Block a user