Compare commits

925 Commits
Author SHA1 Message Date
retoor 694b40bc0e feat: add table plugin to markdown renderer and style table elements in chat messages
Enable the mistune table plugin in the markdown renderer to support rendering of markdown tables. Add comprehensive CSS styling for `.message-content` tables including border-collapse, alternating row colors, hover effects, and responsive overflow handling. Style table headers with accent color background and white text, and apply consistent padding and borders to table cells.
2026-03-15 17:40:39 +00:00
retoor ab60650a09 fix: increase socket close timeout to 300s and client call timeout to 300s
The server-side WebSocket close timeout in SocketService was raised from 5s to 300s
to prevent premature timeout during graceful shutdown. The client-side _callTimeout
in socket.js was increased from 30s to 300s to match the longer server timeout,
ensuring pending RPC calls are not cancelled before the connection fully closes.
2026-03-14 19:00:26 +00:00
retoor 37df158bc9 feat: strip 'export ' prefix from env lines in load_env parser
Handle lines starting with 'export ' in .env files by removing the prefix before splitting key=value, enabling compatibility with shell-exported environment variable formats.
2026-02-16 18:58:02 +00:00
retoor 9b4040a326 perf: increase websocket heartbeat to 300s and load subscriptions asynchronously in RPCView
The heartbeat interval is raised from 30.0 to 300.0 seconds to reduce network overhead and improve connection efficiency. Subscription loading is moved into a background async task to prevent blocking the WebSocket handshake, enhancing responsiveness during initial connection setup.
2026-02-14 08:53:13 +00:00
retoor 304ddc021d chore: bump version to 1.35.0 and auto-assign admin role to first registered user
Update pyproject.toml version from 1.34.0 to 1.35.0 and add logic in UserService.create to set is_admin flag when no users exist yet, ensuring the first registered user gains administrative privileges automatically.
2026-01-31 16:06:30 +00:00
retoor 73a49acd5a feat: add comprehensive admin panel with user, channel, message, and system management views
Implement full admin interface including dashboard stats, user management (edit/ban), channel management (edit/clear/delete members), message/forum/thread/post browsing, file and drive management, notification creation, system KV store, and push notification views. Add AdminService with permission checks and dashboard statistics aggregation. Include admin.css with dark theme styling and extend base.css for profile page layout fixes. Register all admin routes and service in application bootstrap.
2026-01-31 15:54:47 +00:00
retoor fbf67f2b60 refactor: split monolithic rpc.py into modular action handler classes for auth, channels, containers, databases, mentions, messages, and misc 2026-01-24 08:10:46 +00:00
retoor 95193dfe35 feat: add mention functionality with backend models, services, and frontend navigation components 2026-01-24 07:32:30 +00:00
retoor 662b7d30f1 perf: reduce channel message processing max workers from 30 to 1
Lower the ThreadPoolExecutor max_workers parameter in ChannelMessageService from 30 to 1 to limit concurrency and improve performance under resource-constrained conditions. Bump project version from 1.30.0 to 1.31.0 and update CHANGELOG.md to document the change.
2026-01-24 06:24:42 +00:00
retoor e5ea9b626d perf: increase channel message worker pool max_workers from 10 to 30 in ChannelMessageService
Bump project version from 1.29.0 to 1.30.0 and update CHANGELOG to document the performance improvement. The change modifies the `_max_workers` attribute in `src/snek/service/channel_message.py` to allow up to 30 concurrent workers for processing channel messages, improving throughput under high load.
2026-01-17 20:32:02 +00:00
retoor f726ef652a chore: bump version to 1.29.0 and add full site.css with dark sidebar layout and button restyling in index.html 2026-01-17 20:25:19 +00:00
retoor ad58daa412 feat: add architecture, design, api, bots, and contribute doc pages with nav updates and worker increase
Add five new documentation hub pages (architecture, design, api, bots, contribute) with dedicated views and routes in the application router. Increase channel message service max workers from 1 to 10 for improved throughput. Update navigation links in about and index templates to include the new doc pages and the docs hub. Bump project version to 1.28.0.
2026-01-17 20:13:52 +00:00
retoor 5a4b0ed8e0 chore: bump project version to 1.27.0 and reduce starfield particle count from 300 to 200 in sandbox template 2026-01-16 04:24:38 +00:00
retoor bef9ecbe4e chore: bump version to 1.26.0 and double starfield particle count in sandbox template 2026-01-16 04:19:33 +00:00
retoor af415410f9 chore: bump project version to 1.25.0 and increase sandbox starfield count from 50 to 150
- Update pyproject.toml version from 1.24.0 to 1.25.0
- Modify sandbox.html StarField instantiation to use 150 stars instead of 50
- Add changelog entry for version 1.25.0 documenting the HTML file update
2026-01-16 04:06:46 +00:00
retoor fc39777663 chore: bump version to 1.24.0 and refactor mobile header CSS with !important overrides 2026-01-03 17:03:13 +00:00
retoor 257271f184 chore: rename RPC methods to snake_case and bump version to 1.23.0
Rename createChannel, inviteUser, leaveChannel, updateChannel to snake_case equivalents in src/snek/view/rpc.py; update pyproject.toml version from 1.22.0 to 1.23.0; add changelog entry for version 1.23.0
2026-01-03 14:06:47 +00:00
retoor 9ab215f977 chore: bump version to 1.22.0 and update dm channel name resolution in rpc view 2026-01-03 13:49:54 +00:00
retoor ce0e466f0f chore: bump version to 1.21.0 and fix channel name field in RPC subscription
- Update pyproject.toml version from 1.20.0 to 1.21.0
- Change channel name source from subscription["label"] to subscription["description"] in src/snek/view/rpc.py RPCView class
2026-01-03 13:32:36 +00:00
retoor 1635eca7d3 chore: bump version to 1.20.0 and fix header/channel-menu CSS layout for mobile 2026-01-03 13:28:43 +00:00
retoor beb0f6c8ad chore: bump version to 1.19.0 and add responsive channel-menu component with CSS/JS 2026-01-03 13:24:57 +00:00
retoor d6173a01fe chore: replace inline nav links with custom nav-menu web component in app.html 2026-01-03 13:13:55 +00:00
retoor 586aac5eac chore: bump version to 1.17.0 and remove debug prints from channel upload handler 2026-01-03 13:04:12 +00:00
retoor ac645ced24 chore: bump version to 1.16.0 and reposition file upload grid with absolute positioning 2026-01-03 12:56:16 +00:00
retoor fb2470d154 chore: bump version to 1.15.0 and refactor file-upload-grid from fixed grid to flexbox layout
- Update pyproject.toml version from 1.14.0 to 1.15.0
- Add changelog entry for version 1.15.0
- Convert .fug-grid from CSS grid with 6-column fixed layout to flexbox with wrap
- Remove fixed background color on .fug-root and set max-width to 100%
- Adjust tile width to 120px with flex-shrink: 0 and reduce padding/spacing values
2026-01-03 12:52:36 +00:00
retoor 774944e957 feat: add ToolbarMenu web component with STT/TTS/upload controls and status indicators 2026-01-03 12:42:23 +00:00
retoor c520e27b70 feat: refactor chat input toolbar to use toolbar-menu component
Replace direct appending of stt, tts, and upload buttons with a centralized toolbar-menu component. This change moves button management into the new toolbar-menu element, which handles button registration via addButton calls and controls visibility during file upload states. The toolbar-menu is now shown/hidden instead of individual buttons, simplifying the chat input's DOM structure and preparing for future toolbar extensions.
2026-01-03 12:42:07 +00:00
retoor 753da13a44 feat: add _sendKeepListening method to reset transcripts on Enter without stopping recognition
When the user presses Enter while listening, the previous behavior stopped recognition entirely via _stopAndSend(). The new _sendKeepListening() method clears both final and interim transcripts while keeping the recognition session active, allowing continuous dictation across multiple Enter presses without restarting the speech-to-text engine.
2025-12-31 09:57:51 +00:00
retoor ee120c327d fix: prevent empty dots and spaces from being sent as final transcript in STTButton 2025-12-31 09:37:15 +00:00
retoor e01321b631 fix: hardcode speech recognition and TTS voice selection to English only 2025-12-31 09:32:49 +00:00
retoor 817b1d2b66 feat: replace snek-speaker with tts-button and fix multiline string bugs in editor and push
- Remove snek-speaker element from chat-input.js and replace with tts-button
- Rewrite tts.js from a simple SnekSpeaker class to a full TTSButton custom element with voice selection, styling, and speech synthesis integration
- Fix goToLine in editor.js to use literal \n instead of broken template literal split
- Fix push.js alert string to include proper newline separator instead of broken concatenation
- Update web.html template to remove snek-speaker speak logic and always play message sound
2025-12-31 09:31:07 +00:00
retoor 0a39f07cc2 fix: replace broken TTS button with working STT button in chat input
The previous implementation incorrectly wired a TTS button to trigger speech synthesis instead of speech-to-text. This commit removes the misconfigured `ttsButton` and `snekSpeaker` elements, replacing them with a proper `sttButton` that creates an `STTButton` custom element. The `stt.js` module is fully rewritten to implement a functional Web Speech API-based speech recognition component, including start/stop listening, interim and final transcript handling, and keyboard event simulation for inserting recognized text into the chat input field.
2025-12-31 08:59:24 +00:00
retoor f1e60eee61 feat: add asyncio lock to socket connection handling and implement proper cleanup in MessageList
Add an asyncio.Lock to SocketService to synchronize concurrent access to the users and sockets sets during connection and disconnection, preventing race conditions when multiple websocket connections are established or torn down simultaneously. In the frontend MessageList component, replace anonymous inline event listeners with named handler references stored as instance properties, and implement a disconnectedCallback lifecycle method that removes those event listeners and disconnects the MutationObserver, ensuring proper cleanup when the element is removed from the DOM.
2025-12-29 00:25:28 +00:00
retoor 19b50f3bce feat: add channel management dialogs for create, settings, delete, invite, and leave operations
Implement five new dialog interfaces (create, settings, delete, invite, leave) with corresponding CSS styling, JavaScript classes, and RPC integration. Add sidebar add-button styling, update help dialog with new slash commands, and bump version to 1.14.0.
2025-12-26 01:05:06 +00:00
retoor ae2102f081 chore: bump version to 1.13.0 and update pytest config with asyncio_mode 2025-12-24 17:03:21 +00:00
retoor 2048be900e chore: bump version to 1.12.0 and move socket lock checks inside async context managers 2025-12-20 23:25:08 +00:00
retoor eecc4ce1e8 feat: add configurable registration openness via SNEK_REGISTRATION_OPEN env var
Introduce a new Config class in snek/system/config.py that reads environment variables, including SNEK_REGISTRATION_OPEN, to control whether user registration is allowed. The Application now loads this config and passes it to templates and views. The register.html template conditionally shows either the registration form or an invitation-only message based on config.registration_open. The RegisterView returns a 403 JSON error when registration is closed. Version bumped to 1.11.0 and CHANGELOG updated accordingly.
2025-12-19 11:53:58 +00:00
retoor 71329c1262 feat: disable open registration and replace form with invitation-only message on register page 2025-12-19 11:34:02 +00:00
retoor 2308fa20ad chore: add author headers to 148 source files and bump version to 1.9.0 2025-12-18 22:48:50 +00:00
retoor d1520f1b21 chore: bump version to 1.8.0 and add changelog entry for socket robustness improvements 2025-12-18 21:04:01 +00:00
retoor b6476f3bf0 fix: ensure socket cleanup in websocket handler by wrapping async iteration in try/finally block 2025-12-17 21:54:25 +00:00
retoor df8986715d refactor: remove presence debounce and simplify websocket error handling in rpc view 2025-12-17 21:47:21 +00:00
retoor 3dce95e38c chore: remove umami analytics script tag from app.html template 2025-12-17 21:18:42 +00:00
retoor 2534b7572e chore: bump version to 1.4.0 and simplify user dict access in socket service 2025-12-17 21:09:54 +00:00
retoor c712bc58c1 feat: add debounced user presence join/depart notifications with 3-second delay in socket service 2025-12-17 20:54:07 +00:00
retoor cf6e9eec7a chore: remove umami analytics script and csp connect-src from middleware and template 2025-12-17 17:34:32 +00:00
retoor d0eb7fad17 fix: replace safe dict access with direct access and add null check for starField in message-list.js 2025-12-17 17:24:08 +00:00
retoor 4b388e890d fix: replace hardcoded channel_uid with dynamic variable in broadcast and fix db_query signature 2025-12-14 23:10:50 +00:00
retoor 155c914c34 fix: correct UserPropertyModel field name and add missing db commit in service
- Fix UserPropertyModel value field name from "path" to "value" in user_property.py
- Add self.mapper.db.commit() call after upsert in UserPropertyService to persist changes
- Refactor CSS across base.css, buttons.css, inputs.css, generic-form.css, and fancy-button.js to unify dark theme with monospace font, consistent border-radius (4px), and transition effects
- Introduce new buttons.css and inputs.css stylesheets with reusable .btn, .input, .textarea, .select classes and variants (primary, secondary, danger, success, sizes)
- Update sidebar navigation styling with active state indicator and hover border-left highlight
- Revise dialog button styles to match new design system with border, background, and hover states
- Adjust generic-form and generic-field components to use new font, colors, and focus ring (box-shadow) instead of outline
2025-12-05 18:17:09 +00:00
retoor e52c7c4f76 docs: remove obsolete GIT_INTEGRATION.md guide from snek templates
The entire GIT_INTEGRATION.md file was deleted from src/snek/templates/,
removing the outdated Git integration documentation that covered repository
creation, cloning workflows, and web interface features. This file is no
longer needed as the integration details have been superseded by newer
documentation or the feature set has changed.
2025-12-05 17:16:04 +00:00
retoor b93f618d21 feat: add profile page management, default forum creation, and bcrypt dependency
Add ProfilePageMapper, ProfilePageModel, and ProfilePageService to support user profile pages with slug-based routing, ordering, and publish state. Register profile page views in the application router. Implement automatic creation of a default "General Discussion" forum on startup if no active forums exist. Add bcrypt to core dependencies and pytest with asyncio support as optional test dependencies. Refactor forum event dispatch to use inspect for robust async/sync listener handling. Extend RepositoryModel with optional description field. Update SQL schema to include IP geolocation fields on user table and history_start on channel table.
2025-12-05 17:15:36 +00:00
retoor 18cf101080 fix: remove vulnerable code and fix syntax errors in WebSocket and Dataset classes 2025-11-06 03:13:36 +00:00
retoor 4d5a7c0500 docs: expand project quality review with full codebase analysis covering all Python modules in src/snek recursively
The review now includes detailed analysis of every Python file in the project, covering Docker integration, Git support, system modules, and additional architectural patterns discovered during the comprehensive codebase examination.
2025-11-06 03:12:06 +00:00
retoor 490f820f5f docs: add comprehensive project quality review document for Snek codebase
This commit introduces a new review.md file that provides a detailed quality assessment of the Snek project, covering its modular architecture, asynchronous design, feature set, and identified weaknesses including syntax errors in serpentarium.py and sync.py, incomplete code summaries, potential security concerns with WebSocket authentication and raw SQL queries, and code quality issues.
2025-11-06 03:05:42 +00:00
retoor e737dcddb2 feat: add last_read_at tracking and jump-to-first-unread button in chat UI
Add `last_read_at` column to `channel_member` table and set it in `mark_as_read` service method. Implement `get_first_unread_message_uid` RPC endpoint that queries the first message after the member's last read timestamp. Introduce a "Jump to First Unread" button in the web template with `checkForUnreadMessages` and `jumpToUnread` JavaScript functions to show/hide the button and scroll to the unread message element.
2025-11-03 17:08:13 +00:00
retoor 71f94db41f chore: remove aiohttp_debugtoolbar import and commented setup, restyle sidebar typography and link spacing
- Remove unused `aiohttp_debugtoolbar` import and its commented-out setup call in `app.py`
- Increase sidebar top padding from 10px to 20px for better vertical rhythm
- Reduce heading font-size to 0.75em, add uppercase transform, letter-spacing, and margin-top
- Add `:first-child` rule to reset top margin on first sidebar heading
- Tighten list item spacing from 15px to 4px, reduce link font-size to 0.9em
- Add block display, padding, border-radius, and hover background transition to sidebar links
2025-10-28 19:50:53 +00:00
retoor 680fc54661 feat: remove broken clipboard paste handler and simplify import formatting in web template 2025-10-05 20:07:18 +00:00
BordedDev b3e845f9cd feat: add selection-aware paste handling with undo support in chat input
Implement paste event handler that respects text selection by replacing selected content with clipboard data, and wrap the operation in an undoable transaction to allow users to revert paste actions. Remove previous clipboard item type detection logic in favor of a simpler, selection-aware approach.
2025-10-05 19:03:35 +00:00
retoor e0f29d044d fix: replace video elements with text src in reply preview to prevent autoplay
When rendering reply previews, video elements were being cloned from the original message, causing them to autoplay in the reply area. This change applies the same text-replacement logic already used for images and iframes, extracting the video source URL and replacing the `<video>` tag with a plain text node containing the URL.
2025-10-04 17:05:23 +00:00
retoor ec216eeb2d fix: strip trailing whitespace and add clipboard paste upload handler in web template 2025-10-04 17:01:54 +00:00
BordedDev 94709e1831 feat: replace video elements with plain text src in message list rendering
Add logic to strip <video> tags from quoted messages by extracting their src attribute and replacing the element with a text node, mirroring the existing handling for images and iframes. This prevents embedded video players from appearing in reply previews while preserving the source URL as readable text.
2025-10-04 14:39:00 +00:00
BordedDev db1e7902ba fix: strip trailing whitespace and add file upload from clipboard paste in web template 2025-10-04 14:30:54 +00:00
retoor 57d86e2620 feat: cache generated avatars in app storage to avoid redundant multiavatar calls 2025-10-02 14:28:38 +00:00
retoor bbe8022eb1 fix: enable cache by default and remove async lock from get/set methods 2025-09-30 17:54:39 +00:00
retoor c6c8ea1c30 fix: disable cache by default and remove version comment in Cache class
The cache is now disabled by default (self.enabled = False) to prevent stale data serving until explicitly enabled. Additionally, the inline comment documenting the version calculation was removed to clean up the code.
2025-09-30 17:47:17 +00:00
retoor d139731720 fix: enable cache by default and add async lock for thread safety in Cache class 2025-09-30 17:35:56 +00:00
retoor a35415f549 chore: remove obsolete prototype SSH server scripts from src/snekssh directory 2025-09-30 17:28:59 +00:00
retoor 2234147845 fix: replace dict with OrderedDict for O(1) LRU operations and disable cache by default 2025-09-30 17:27:07 +00:00
retoor 34adf19bbe feat: refactor cache to use OrderedDict with async lock for thread-safe LRU eviction
Replace the plain dict cache with an OrderedDict-based implementation that
provides O(1) LRU eviction by re-inserting accessed items at the end.
Introduce an asyncio.Lock to ensure thread-safe concurrent access during
get/set operations. Enable caching by default (self.enabled = True) and
add import for asyncio and OrderedDict.
2025-09-30 17:14:16 +00:00
retoor 497cc0b9f7 fix: wrap synchronous _execute call in asyncio.to_thread for non-blocking mapper execution 2025-09-30 15:36:22 +00:00
retoor 0a05da7067 refactor: remove unused prepare_stats startup hook and stats structure from Application class 2025-09-30 15:20:02 +00:00
retoor 05272b9a72 fix: remove deprecated stats.html route from router setup 2025-09-30 15:18:48 +00:00
retoor cd39881fd4 feat: remove deprecated stats module and its middleware from application stack
Remove the entire `snek.system.stats` module including its middleware, statistics structure creation, and websocket stats tracking. This eliminates the `stats_middleware` from the application middleware chain, removes the `update_websocket_stats` calls from the RPC view handler, and deletes the 129-line stats module with its time-series SVG generation and granularity-based counter logic.
2025-09-30 15:17:06 +00:00
retoor 98b21c1bf5 feat: add logging configuration with basicConfig at INFO level in __main__.py 2025-09-22 12:15:53 +00:00
retoor 837010302c feat: add render_template timing instrumentation and disable cache in system modules
- Add performance timing with time.perf_counter() to render_template method in Application class, printing elapsed seconds to stdout
- Disable cache by setting self.enabled = False in Cache.__init__ to bypass caching behavior
- Add early return in ChannelMessageService.maintenance() to skip message iteration loop
- Remove default_limit class attribute assignment in BaseMapper.__init__ and add time import
- Refactor run_in_executor to execute synchronously with _execute() call and wrap semaphore around a second _execute() call instead of using thread pool executor
2025-09-22 12:14:30 +00:00
retoor 6e75d38979 chore: comment out debug console logs in Chat and ChatInputComponent 2025-09-22 11:30:53 +00:00
retoor 37dccf9da2 fix: remove trailing semicolons and fix whitespace inconsistencies in chat-input.js
- Removed trailing semicolons from arrow function bodies in hiddenCompletions object
- Fixed inconsistent indentation for _leetSpeak toggle and textarea classList assignment
- Replaced spread operator with Object.assign for allAutoCompletions getter
- Truncated extractMentions regex pattern at diff boundary
2025-09-17 20:55:31 +00:00
retoor 538d92b538 fix: guard mention replacement against null closestAuthor in chat input 2025-09-17 14:30:20 +00:00
retoor e980265aeb fix: fix whitespace formatting and trailing commas in chat-input.js 2025-09-17 14:27:04 +00:00
retoor 2f74ae9e74 fix: set closestAuthor to 0 when minDistance drops below 5 in chat input mention detection 2025-09-17 14:20:59 +00:00
retoor a16ccd82fe feat: add new chat view with WebSocket-based messaging component
Introduce a new `NewView` class in `src/snek/view/new.py` that renders the `new.html` template, requiring authentication. The template includes a custom `ChatWindow` web component that establishes a WebSocket connection via the `Socket` class, handles channel-based messaging with real-time updates, and supports sending messages on Enter key press while allowing Shift+Enter for multi-line input.
2025-09-17 14:12:05 +00:00
retoor dc1b36e68e feat: add NewView route and fix leet check and push notification handling
- Register NewView at /new.html endpoint in Application router
- Fix textToLeetAdvanced to require s.length > 1 for valid leet match
- Comment out event.waitUntil(reg) in service-worker push handler to prevent potential hang
2025-09-17 14:09:48 +00:00
retoor 4a51c2d386 fix: add console.info logging and increase reconnect delay to 4 seconds in socket.js
- Added console.info call to log event name and data payload in the data handler
- Changed reconnect timeout from 0ms to 4000ms to introduce deliberate delay before reconnection attempt
2025-09-16 02:46:44 +00:00
retoor 51ed3b2fd2 fix: reduce default ProcessPoolExecutor max_workers from 3 to 1 in ChannelMessageService init 2025-09-15 03:00:11 +00:00
retoor 301c55cfdd fix: add debug logging for closest author and min distance in chat input component 2025-09-15 00:22:03 +00:00
retoor 0104f5ef85 fix: increase default max_workers to 3 and use instance variable for executor pool size 2025-09-15 00:12:52 +00:00
retoor fe57aab360 fix: increase ProcessPoolExecutor max_workers from 1 to 10 in channel message service 2025-09-15 00:09:04 +00:00
retoor c5ea835dff fix: reduce default ProcessPoolExecutor workers to 1 and add debug logging
The change reduces the default `max_workers` for `ProcessPoolExecutor` from 5 to 1 in the `get_or_executor` method of `ChannelMessageService`, and adds a debug print statement to log the number of available executors.
2025-09-14 22:58:29 +00:00
retoor b6ada2c753 chore: add duplicate defer attribute to umami analytics script tag in app.html 2025-09-14 20:53:05 +00:00
retoor 5cd88ccb9b fix: prevent premature removal of non-final messages in upsertMessage
The change modifies the conditional logic in `MessageList.upsertMessage()` to only remove and nullify an existing message when `data.message` is falsy, instead of doing so when `data.is_final` is true or `data.message` is falsy. This ensures that non-final messages with content are not incorrectly removed from the DOM before being updated or re-inserted.
2025-09-09 21:40:33 +00:00
retoor 8b15e08014 feat: replace ThreadPoolExecutor with ProcessPoolExecutor and add Jinja2 template rendering for channel messages 2025-09-08 04:08:12 +00:00
retoor 7a0f58451a fix: reduce per-user executor pool max_workers from 5 to 1 in ChannelMessageService
The change limits concurrent thread execution per user to a single worker,
preventing resource exhaustion from unbounded thread creation in the
get_or_create_executor method.
2025-09-07 23:09:22 +00:00
retoor 088dbbf3da feat: replace global thread pool with per-user executor pools in ChannelMessageService
Remove the module-level `executor` variable and introduce instance-level `_executor_pools` dict with `get_or_create_executor` method that creates a `ThreadPoolExecutor(max_workers=5)` per user UID. Update all `run_in_executor` calls in `render_message` and `render_message_edit` to use the per-user executor instead of the shared global one, improving isolation and resource control across concurrent user requests.
2025-09-07 22:59:11 +00:00
retoor 8d4511a2bf fix: prevent avatar anchor from being overwritten by later matches in message-list.js
The loop iterating over avatar elements now only assigns the first matching anchor to `lastElement`, ensuring the scroll target remains the earliest occurrence instead of being overwritten by subsequent matches.
2025-09-07 00:42:47 +00:00
retoor 77d7186c86 fix: add debug print before socket deletion in RPCView error handlers
Add a debug print statement in the `send` method's exception handler to log the exception message before deleting the socket, and update the existing debug print in the message processing loop to include a distinctive prefix for easier log filtering.
2025-08-31 01:41:41 +00:00
retoor 86b0ae6db4 fix: correct indentation of user_availability_service method in socket.py
The method definition was incorrectly placed at module level instead of being properly indented as a member of the SocketService class. This fix moves the async def user_availability_service declaration one level deeper to align with the class body, restoring correct Python scoping and preventing AttributeError or runtime import failures when the service attempts to register or invoke this method.
2025-08-31 01:27:47 +00:00
retoor 7984feeb6d chore: add debug logging to user availability service loop
Add detailed debug-level log statements throughout the
user_availability_service method to trace execution flow, including
entry into the main loop, socket iteration, connection status checks,
user update decisions, database save operations, and sleep intervals.
2025-08-31 01:25:26 +00:00
retoor 50e800e5f2 fix: offload Jinja2 template rendering and sanitization to thread pool executor to avoid blocking async event loop 2025-08-31 01:22:00 +00:00
retoor f8670d8a38 feat: implement modal editor with vim-like modes and command-line interface
Replace the basic contenteditable editor with a full modal editor supporting insert, normal, and visual modes. Add a command-line interface at the bottom for executing commands like :w, :q, and :wq. Introduce mode-specific cursor styling, selection highlighting, and keyboard-driven navigation. The editor now tracks mode state, handles mode transitions via Escape and i/v keys, and displays the current mode in a status bar.
2025-08-06 13:33:13 +00:00
retoor c9673b7744 fix: copy user socket list before iteration to avoid mutation during send 2025-08-06 12:18:01 +00:00
retoor 791ef0bc13 feat: add sentry-sdk dependency and initialize sentry in main function
- Added sentry-sdk to project dependencies in pyproject.toml
- Imported and initialized sentry_sdk with DSN in main() function
- Added graceful fallback with print message if sentry_sdk import fails
2025-08-06 09:51:58 +00:00
retoor 448034e03e feat: disable login requirement for ChannelAttachmentView to allow public access to attachments 2025-08-03 01:31:08 +00:00
retoor 01a14370c9 fix: remove random sleep before rpc call in websocket message handler 2025-08-01 00:06:20 +00:00
retoor dbc34cebb1 fix: reduce randomized sleep range in websocket RPC handler from 0.1-0.4s to 0.01-0.1s 2025-07-31 23:59:23 +00:00
retoor b760fdd22b fix: add random sleep before RPC message processing in websocket handler
- Imported `random` module to support randomized delay
- Inserted `await asyncio.sleep(random.uniform(0.1, 0.4))` before `await rpc(msg.json())` in the websocket message loop
- This introduces a jitter of 100-400ms to desynchronize concurrent RPC processing from multiple clients
2025-07-31 23:57:18 +00:00
retoor 2cc616c8aa fix: move endOfMessages.after call before scroll check in MessageList
The `endOfMessages.after(message)` call was placed after the `isScrolledToBottom()` check, causing the scroll position to be evaluated before the new message was inserted into the DOM. This resulted in incorrect scroll behavior when messages were added, as the scroll check would not account for the newly inserted element. Moving the insertion before the scroll check ensures the scroll position is evaluated after the DOM has been updated with the new message.
2025-07-30 12:49:16 +00:00
retoor 32ed12f3db fix: correct missing closing parenthesis in upsertMessage condition check 2025-07-30 12:46:10 +00:00
retoor 1f89849c9a fix: correct conditional grouping in upsertMessage to remove stale messages
The diff shows a fix in the `upsertMessage` method of the `MessageList` class in `src/snek/static/message-list.js`. The original condition `(message && data.is_final) || !data.message` was incorrectly grouping the logical OR, causing messages to be removed only when `data.is_final` was true alongside a message, or when `data.message` was falsy regardless of message existence. The fix wraps the entire condition in parentheses: `(message && (data.is_final || !data.message))`, ensuring that a message is removed only if it exists AND either `data.is_final` is true or `data.message` is falsy. A comment `// TO force insert` was added to clarify the intent of nullifying the message reference after removal.
2025-07-30 12:44:25 +00:00
retoor 77802883fe fix: skip upsert for empty messages and clear stale entries in MessageList 2025-07-30 12:41:23 +00:00
retoor aa3d00243f fix: remove stale message from DOM only when is_final flag is set
Previously, the upsertMessage method removed any existing message element from the DOM whenever a message with the same uid was received, regardless of whether the update represented a final version. This caused premature removal of messages that were still being streamed or updated incrementally. Now the removal is gated on the `data.is_final` property, ensuring that only completed messages are replaced in the DOM while in-progress messages remain visible until their final state arrives.
2025-07-30 12:36:56 +00:00
retoor 9bad9a78ed feat: add HEAD route and lock-checking middleware to WebDAV handler 2025-07-30 01:25:36 +00:00
retoor c5df9e6463 feat: add MS-Author-Via header to WebDAV OPTIONS response and fix semicolons in message-list.js
- Add "MS-Author-Via: DAV" header to WebDAV OPTIONS handler for Microsoft WebDAV client compatibility
- Fix missing semicolons in message-list.js (LONG_TIME constant, ReplyEvent constructor)
- Remove trailing whitespace in import statement formatting
2025-07-30 00:51:26 +00:00
retoor 299f617988 fix: re-enable iframe elements in sanitize_html by commenting out removal logic 2025-07-26 21:56:24 +00:00
retoor 942098583d feat: add XSS sanitization to channel message rendering and refactor sanitize_html with BeautifulSoup
Add sanitize_html calls in ChannelMessageService for both render and save paths to strip script, iframe, object, embed tags and event handler attributes from rendered HTML templates. Refactor sanitize_html in template.py to use BeautifulSoup for tag removal and attribute cleaning, replacing the previous bleach-only implementation with a new sanitize_html2 function retained for compatibility.
2025-07-25 15:17:45 +00:00
retoor 8762e592d0 fix: remove whitelist_attributes call from message template rendering in ChannelMessageService
The whitelist_attributes filter was applied to the rendered HTML output in both
the get_by_uid and save methods of ChannelMessageService. This filter was
stripping allowed HTML attributes from the message content, which could break
formatting or functionality in rendered messages. The fix removes the filter
call, allowing the full rendered template output to be stored and returned
without attribute sanitization.
2025-07-25 15:10:03 +00:00
retoor 0a45ef2b8f fix: move whitelist_attributes call to post-render in channel message service 2025-07-25 15:05:41 +00:00
retoor bc924f4f85 fix: reduce default star count from 100 to 50 in StarField constructor and instantiation 2025-07-24 11:48:22 +00:00
retoor 5e92d9ce77 feat: add once option to event listeners and implement removeEventListener method
Extend EventHandler.addEventListener to accept an optional `once` parameter that auto-removes the handler after first invocation. Implement removeEventListener method to support manual removal of specific handlers and cleanup of empty subscriber arrays. Update Socket.call method to use the new once option for one-shot RPC response listeners.
2025-07-24 21:36:50 +00:00
retoor b13762f6f8 fix: remove unused isVisible method and fix sibling tracking in message list rendering
- Remove the `isVisible()` method from `MessageElement` class as it was unused and caused unnecessary DOM queries
- Fix `siblingGenerated` tracking to store the actual next sibling reference instead of a boolean, preventing stale state when elements are reordered
- Move `endOfMessages` div creation before observer setup to ensure proper initialization order in `MessageList`
2025-07-24 21:35:49 +00:00
BordedDev 3ea97f1e97 fix: remove unused isVisible method and fix sibling tracking in message rendering
- Remove the unused `isVisible()` method from `MessageElement` class to clean up dead code
- Fix `siblingGenerated` tracking logic: change from boolean to storing the actual next sibling reference, ensuring proper re-evaluation when DOM changes occur
- Move `endOfMessages` element creation before the observer loop to ensure it's observed for intersection events
2025-07-24 14:14:51 +00:00
BordedDev 3aaad348d0 feat: add once option to addEventListener and implement removeEventListener method
- Extend addEventListener to accept an optional `{ once: true }` parameter that auto-removes the handler after first invocation
- Add removeEventListener method to EventHandler class for explicit handler removal
- Clean up empty subscriber arrays by deleting the type key when last handler is removed
- Refactor Socket.call to use the new once option instead of manual handler management
2025-07-24 13:59:17 +00:00
retoor 4e31728de4 fix: move whitelist_attributes call before template render to prevent xss in channel messages 2025-07-23 23:46:34 +00:00
retoor d015e81069 feat: add is_dm property to ChannelModel and auto-join dm channels for members 2025-07-23 23:07:33 +00:00
retoor d0b025c7a5 fix: fix scrolling, reply handling, cross-channel messages, and gg navigation in chat UI 2025-07-20 17:55:47 +00:00
BordedDev 6bddaeb6a4 fix: strip embed links and media from reply text and trigger change event on reply insertion 2025-07-20 01:38:18 +00:00
BordedDev e0a253521a refactor: replace click-based reply delegation with custom ReplyEvent in message-list
Replace the global click delegation on `messagesContainer` that checked for anchor tags with `href="#reply"` by introducing a dedicated `ReplyEvent` class. The event is now dispatched from within `MessageElement` when the reply link is clicked, carrying the message text target directly. This moves the reply logic from a fragile DOM traversal to a composed, bubbling custom event, improving encapsulation and maintainability.
2025-07-19 23:11:05 +00:00
BordedDev b4e67834d1 fix: guard against null parentElement and missing _originalChildren in message upsert 2025-07-19 21:31:23 +00:00
BordedDev 1f187c616c fix: invert scrolled-to-bottom guard in scrollToBottom to always scroll when not at bottom 2025-07-18 22:13:06 +00:00
BordedDev c09c8acc93 fix: correct double-g scroll target to last message element
The double-g press handler was incorrectly scrolling to the first child of the messages container instead of the last element, causing the viewport to jump to the top of the conversation rather than the most recent message. Changed the selector from `.message:first-child` to `lastElementChild` to properly scroll to the latest message before triggering `loadExtra()`.
2025-07-18 22:00:29 +00:00
BordedDev 58f53ee252 fix: prevent cross-channel message updates and fix reply click handler with instant scroll
- Guard update_message_text handler to only upsert messages already in the local map, avoiding cross-channel text updates
- Add delegated click listener on messagesContainer for #reply links, extracting message text for reply input
- Change scrollToBottom default behavior from 'smooth' to 'instant' for immediate positioning
- Fix loadExtra to use lastElementChild and reverse fetched messages for correct prepend order
- Correct isScrolledPastHalf logic to use absolute scrollTop value for reliable detection
2025-07-18 21:57:41 +00:00
retoor 0d5b91b6b4 fix: replace async iteration with sync find and add exception handling in ChannelMessageService.maintenance
Refactor the maintenance method to iterate over channel messages using synchronous `self.mapper.db["channel_message"].find()` instead of the async `self.find()` generator, wrapping each message processing in a try-except block that catches exceptions, prints them, and sleeps for 0.1 seconds before continuing.
2025-07-18 21:20:46 +00:00
retoor 4e66c895a6 fix: ensure message is fetched before save when html lacks chat-message tag 2025-07-18 15:49:01 +00:00
retoor b6afdca6d3 fix: replace redundant fetch-save pattern with direct save call in channel message service 2025-07-18 15:44:45 +00:00
retoor 56f313c88e feat: add request statistics middleware with time-series tracking and stats endpoint
- Import and register stats_middleware in the application middleware stack
- Add prepare_stats startup hook to initialize stats data structure
- Register /stats.html GET route with stats_handler for viewing statistics
- Create new snek/system/stats.py module with time-series data collection and SVG chart generation
- Integrate websocket statistics tracking in RPC view by calling update_websocket_stats before and after method execution
2025-07-17 22:43:37 +00:00
retoor 932fd98d64 chore: refactor message rendering to use custom elements and reverse display order 2025-07-17 22:17:33 +00:00
BordedDev 04ea146221 fix: prevent terminal scroll jump on blur by deferring input reset and aligning scroll anchor
The blur handler in chat-input.js now calls updateFromInput with the current value and isFinal=true before resetting, ensuring the live-typing send uses the correct isFinal flag to avoid premature message dispatch. In message-list.js, scrollToBottom anchors to block 'start' instead of 'end' to keep the terminal view stable when new messages arrive. The app.html keydown listener also receives the event parameter to properly handle Escape key detection.
2025-07-17 21:21:15 +00:00
BordedDev ccd443055b refactor: replace inline message template with custom chat-message web component and reverse message order
The diff replaces the static message HTML template with a custom `<chat-message>` web component, reverses message rendering order in the template, updates CSS to use `column-reverse` for chat layout, and adjusts scroll logic to append new messages at the end instead of prepending at the beginning.
2025-07-17 00:22:47 +00:00
retoor 5bdf00fdd0 feat: add uploadResponses tracking array and reset after publish in file upload grid
The uploadResponses array is initialized in the component constructor to collect file upload results. The reset() call is moved inside the conditional block that fires when all uploads are complete, ensuring the component state is cleared only after the 'file-uploads-done' event has been published with the full response data. A console.info log is added for debugging the collected responses.
2025-07-14 17:11:46 +00:00
retoor 7af32a5036 feat: migrate forum queries to async generators and replace find_one with get method
- Convert ForumModel.get_threads and ThreadModel.get_posts from returning lists to async generators yielding individual records
- Replace deprecated `_order_by` and `_offset` parameters with `order_by` string syntax in service queries
- Add `get_timestamp` helper and `save` method to BaseModel for direct persistence
- Refactor ForumView endpoints to use `services.forum.get` and `services.thread.get` instead of `find_one`
- Inject request reference into app context via `setattr(self, "request", request)` in view handlers
- Restructure forum HTML template to extend app layout with breadcrumb navigation and dark theme styling
2025-07-12 18:58:19 +00:00
retoor b6240803ab feat: add uuid generation and public notify method to BaseForumService, fix WebSocket handler method name
- Add `generate_uid` method using uuid4 to BaseForumService
- Expose public `notify` method for external event dispatching
- Rename `websocket_handler` to `get` in ForumWebSocketView for proper routing
- Fix WebSocket ID generation to use forum service instance instead of generic services
2025-07-12 01:01:39 +00:00
retoor 91249ff2a1 feat: integrate forum sub-application with models, mappers, services, and WebSocket support
Add complete forum feature including ForumApplication sub-app mounted on parent, new ForumModel/ThreadModel/PostModel/PostLikeModel entities, corresponding mappers and services, event notification dispatch system, and WebSocket endpoint for real-time updates. Refactor service registry to use dynamic registration pattern.
2025-07-12 00:41:57 +00:00
retoor 9493c63396 feat: add PROPGET, PROPSET, PROPDEL routes and property file management to WebDAV handler
Implement three new WebDAV property routes (PROPGET, PROPSET, PROPDEL) alongside property file lifecycle management: delete properties on file removal and move properties on file rename via shutil.
2025-07-10 01:25:40 +00:00
retoor ae6ae150fc fix: remove Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers from CORS middleware 2025-07-08 21:54:28 +00:00
retoor 879148bc2c feat: add Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers to CORS middleware
Add `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: credentialless` headers to the `cors_middleware` response in `src/snek/system/middleware.py`, with a commented-out alternative for `require-corp` to support cross-origin isolation configuration.
2025-07-08 21:35:31 +00:00
retoor 1d496655c4 feat: prioritize secure_url over url for og:image and og:video extraction in embed_url 2025-07-05 23:00:13 +00:00
BordedDev 36cf5d8109 feat: prioritize secure_url over url for og:image and og:video in embed_url
Reorder the fallback chain for Open Graph image and video extraction in
`src/snek/system/template.py` to check `secure_url` before `url` and `image`/`video`,
ensuring HTTPS resources are preferred when available for embedded player support.
2025-07-05 16:00:58 +00:00
retoor b90079e858 fix: set fallback Content-Type to application/octet-stream for untyped file downloads
When a file has an unknown or empty MIME type (original_format is None or empty string),
the response now defaults to "application/octet-stream" instead of sending an empty
Content-Type header, which caused browsers to mishandle the download.
2025-07-04 20:29:58 +00:00
retoor 27bca9c295 feat: add time_cache decorator to block_code method and import time_cache
- Import time_cache alongside existing time_cache_async from app.cache
- Apply @time_cache with 60-minute timeout to block_code method in MarkdownRenderer
- Fix minor whitespace formatting issues in markdown.py
2025-07-04 08:38:08 +00:00
retoor f2c67c02d0 fix: remove async semaphore and executor wrapping from BaseMapper execute method
The synchronous _execute call replaces the previous async pattern that used a semaphore
and run_in_executor, simplifying the execution path and removing unnecessary async overhead
for database operations that are already synchronous in nature.
2025-07-04 07:32:03 +00:00
retoor 9f578f1534 refactor: move semaphore to class level and simplify run_in_executor retry logic
The semaphore is now a class-level attribute shared across all instances, and the retry loop with exponential backoff is removed in favor of a single execution path that always acquires the semaphore. The commit call is conditionally performed only when use_semaphore is True, and the template's live-type attribute is flipped from "false" to "true" to enable live chat functionality.
2025-07-04 07:29:03 +00:00
retoor 1cca195b76 fix: correct variable shadowing and add async/await in service worker push handler
- Renamed shadowed `clients` variable to `matchedClients` in `isClientOpen` function to avoid conflict with global `clients` API
- Changed push event listener to async and added `await` before `isClientOpen` call to properly handle the returned promise
2025-07-04 04:03:53 +00:00
retoor 52f14b7c0f fix: comment out patchConsole call in sandbox StarField initialization 2025-07-04 03:50:54 +00:00
retoor 3fe014c9e8 fix: suppress push notification display when client window is already focused 2025-07-04 03:40:19 +00:00
retoor bd643b63e6 fix: disable live-type attribute on chat-input component in web template 2025-07-04 03:16:56 +00:00
BordedDev 64f86fa778 fix: fallback to application/octet-stream for untyped attachment downloads 2025-07-01 23:03:09 +00:00
retoor 9220bd36f5 fix: correct typo in UserService.search method from kwarggs to kwargs 2025-07-01 08:43:15 +00:00
retoor 763fd9aabe fix: replace punctuated text assignment with space in STTButton typing simulation 2025-06-29 19:13:30 +00:00
retoor 0e6b01a468 fix: replace empty string with space in STTButton simulateTypingWithEvents call
The change modifies the simulateTypingWithEvents call in the STTButton class to pass a single space character instead of an empty string, ensuring the typing simulation triggers proper input event handling for interim speech-to-text results.
2025-06-29 19:10:57 +00:00
retoor 3b659e0db1 fix: restore empty simulateTypingWithEvents call in STTButton interim handler 2025-06-29 19:09:03 +00:00
retoor acdeaaa980 fix: replace interim text replacement with direct sendMessage call in STTButton
The diff modifies the speech-to-text button handler in `src/snek/static/stt.js` to change behavior when interim speech recognition results are received. Previously, the code replaced the input element's value with an empty string and then simulated typing events to insert the interim text. The new implementation sets the input element's value directly to the interim text and calls `document.querySelector('chat-input').sendMessage(interim)` to immediately send the recognized speech as a chat message, bypassing the typing simulation entirely. The old typing simulation logic is commented out.
2025-06-29 19:06:27 +00:00
retoor f1d49a285d fix: remove interim text display and set typing delay to zero in STTButton
The diff modifies two key behaviors in the speech-to-text button component. First, the typing simulation delay parameter is changed from 1 to 0 in the `simulateTypingWithEvents` call within the finalize path, eliminating artificial typing delay when inserting punctuated text. Second, the interim speech recognition handling is refactored: instead of displaying interim text in the input field, both contenteditable and regular input elements now clear the previous interim text by setting their value to an empty string, while the commented-out `simulateTypingWithEvents` call for interim text is uncommented and activated to handle interim updates through the typing simulation mechanism instead of direct value assignment.
2025-06-29 19:03:33 +00:00
retoor 4ac19ef558 chore: refactor STTButton event handling and CSS formatting in stt.js 2025-06-29 18:56:44 +00:00
retoor 1ec7ac8327 chore: fix indentation and formatting in STTButton simulateTypingWithEvents method 2025-06-29 18:34:25 +00:00
retoor 4d908acac0 feat: conditionally skip TTS speech when speaker is disabled in web UI
Add a check for the `snek-speaker` element's enabled state before calling `speak()`. When the speaker is disabled, fall back to playing the message sound instead of attempting speech synthesis. This prevents silent failures and ensures consistent audio feedback based on user preference.
2025-06-29 18:16:15 +00:00
retoor 09fdaeb45f feat: add SnekSpeaker custom element with speech synthesis toggle and voice selection
Introduce a new `snek-speaker` web component that wraps the Web Speech API. The element provides `speak()`, `toggle()`, `stop()`, and `enable()`/`disable()` methods, along with an `enabled` property. Voice selection defaults to the first male English voice found, with asynchronous voice loading handled via the `onvoiceschanged` event.
2025-06-29 18:08:13 +00:00
retoor 3708156acf feat: integrate TTS playback and wire snek-speaker into chat input and message flow
Add snek-speaker element to chat-input.js, attach click handler on ttsButton to enable speaker, and include tts.js module in app.html. In web.html, trigger snek-speaker.speak() on final channel messages to enable text-to-speech output for incoming messages.
2025-06-29 17:58:40 +00:00
retoor e047e5ecb3 feat: make simulateTypingWithEvents return a promise and finalize chat message after typing
Refactor simulateTypingWithEvents to return a Promise that resolves when typing completes, enabling callers to chain actions after the typing simulation finishes. Update the speech recognition result handler to await this promise and call finalizeMessage() on the chat-input element, replacing the previous triggerEvent('keyup', "Enter") approach. Also add line breaks after punctuation in the committed text before typing simulation.
2025-06-29 15:39:32 +00:00
retoor 2c6a3ddf00 feat: add youtube shorts support and refactor embed metadata extraction
Add "/shorts" path to allowed YouTube URL patterns in embed_youtube function. Extract metadata retrieval logic into reusable get_element_options helper that queries twitter, ograph, meta, and element tags from head_info with fallback priority.
2025-06-29 14:24:33 +00:00
retoor 735abf80d0 feat: add login_required flag to views and fix BaseService.get uid handling
Add `login_required = True` to ChannelDriveApiView, ChannelAttachmentView, ChannelAttachmentUploadView, ChannelView, ContainerView, DriveView, BareRepoNavigator, StatsView, StatusView, ThreadsView, UploadView, and UserView to enforce authentication on these endpoints. Set `login_required = False` on RegisterFormView to allow unauthenticated registration. Refactor `BaseService.get` to accept positional args for uid and only filter deleted_at when not explicitly provided. Remove the deprecated `DriveView222` class entirely.
2025-06-29 14:19:53 +00:00
retoor f8bd028569 fix: replace Enter keydown with keyup and reduce typing delay to 1ms in stt.js 2025-06-28 22:12:07 +00:00
retoor fa50562726 feat: add simulateTypingWithEvents method and fix punctuation insertion in STTButton 2025-06-28 22:04:42 +00:00
retoor 93ceda2286 fix: scope image src extraction to text container in message rendering
The previous implementation incorrectly queried all images within the entire message div, causing duplicate or out-of-context image source concatenation. This change restricts the querySelectorAll to only images nested inside the `.text` element, ensuring each message's image sources are appended exactly once and only from the relevant content area.
2025-06-27 15:38:35 +00:00
retoor 02945616e0 feat: strip markdown image syntax from replied image URLs in web template
Remove the '![Replied image]()' wrapper around image sources when constructing reply text in the web chat template, leaving only the raw URL to prevent broken markdown rendering in non-markdown contexts.
2025-06-27 15:36:27 +00:00
retoor e5ca3df33e feat: add image source to reply text when generating reply link in web template 2025-06-27 15:33:23 +00:00
BordedDev 0eeec72acb feat: add /shorts path support to youtube embed url detection
Extend the allowed path prefixes in embed_youtube to include "/shorts",
enabling embedding of YouTube Shorts videos alongside regular watch and
embed URLs.
2025-06-27 10:10:16 +00:00
retoor aaf6b7241d fix: replace activeElement target with textarea query for speech-to-text input
The speech-to-text component previously used `document.activeElement` to determine the target element for transcription output, which failed when no input was focused. Changed the `targetEl` getter to query for a `textarea` element instead, ensuring consistent target availability. Also updated the DOM update logic to call `.focus()` on the target and assign directly to `.value` instead of using `setAttribute("value", ...)`, enabling proper text insertion into the textarea.
2025-06-25 20:46:07 +00:00
retoor 9dbbcccf15 feat: add speech-to-text button component and integrate into chat input
- Create new STTButton web component in src/snek/static/stt.js with shadow DOM, speech recognition API integration, and pulsing visual feedback during listening state
- Add stt.js script import to app.html template to register the custom element globally
- Instantiate and append stt-button element inside ChatInputComponent constructor in chat-input.js
- Add CSS class "chat-input-textarea" to the textarea element for styling hooks
- Attach change event listener on textarea to sync value and trigger updateFromInput on manual edits
2025-06-25 20:41:55 +00:00
retoor 564362ead9 fix: remove no_save context and simplify message update validation in RPCView
The update_message_text method in RPCView was refactored to remove the no_save context wrapper and streamline validation logic. The commented-out time-based restriction check was also removed, allowing message updates without the previous 8-second window limitation.
2025-06-25 19:38:26 +00:00
retoor 75e658d310 feat: add unique uid index to channel_message and remove time-limit check in RPC message update
Add a unique index on the `uid` field in the `channel_message` collection to enforce document uniqueness at the database level. In the RPC message update handler, remove the 8-second time-limit check and instead validate that the message is not marked as final or deleted before allowing edits.
2025-06-25 19:23:31 +00:00
retoor 9fdda81fcc feat: add composite and single-field indexes to channel_message collection on save
Add lazy index creation for `['is_final','user_uid','channel_uid']` and `['deleted_at']` fields in `ChannelMessageService.save()` method, guarded by a `_configured_indexes` flag to ensure indexes are created only once per service instance.
2025-06-25 19:17:37 +00:00
retoor 8b8d5ac862 fix: filter out deleted and finalized messages in RPC message retrieval
- Added `deleted_at=None` filter to `check_message` query in `send_message` to exclude soft-deleted messages from finalization queue
- Added `is_final=False` and `deleted_at=None` filters to `get_message` in `update_message` to prevent editing finalized or deleted messages
- Added early return with error response when message is not found after applying new filters
- Added conditional finalization logic: only queue finalization for non-final messages, cancel existing finalization task for already final messages
2025-06-25 18:59:22 +00:00
retoor 9e5fd33927 fix: replace fixed 7-second sleep with cancellable per-second loop in _finalize_message_task
The previous implementation used a single `await asyncio.sleep(7)` which could not be interrupted
mid-wait when the finalize task was cancelled. The new approach loops 7 times with 1-second sleeps,
checking `self._finalize_task` after each second and returning early if the task reference is cleared,
plus catching `CancelledError` to handle explicit cancellation gracefully.
2025-06-25 16:22:16 +00:00
retoor 45d8707fe7 fix: add early return guard for empty message in RPCView message handler
When message is empty after processing check messages, the handler now returns early
instead of proceeding to send an empty message through the chat service. This prevents
unnecessary API calls and potential errors from sending blank content.
2025-06-25 16:11:16 +00:00
retoor 0c3474da05 fix: replace scrollTop assignment with scrollIntoView and fix empty message display logic
- Comment out direct scrollTop assignment in scrollToBottom method and use scrollIntoView on the bottom anchor element instead
- Change display toggle from messageDiv to textElement in updateMessage to correctly hide empty text content while preserving message container layout
2025-06-25 16:07:11 +00:00
retoor dbb219e469 fix: correct empty message hiding logic and improve message finalization flow
- Move display toggle from textElement to messageDiv in message-list.js to properly hide entire message container when text is empty
- Remove is_final=False filter in _finalize_message_task to allow retrieval of already finalized messages
- Add early return when message is already finalized to prevent duplicate finalization attempts
- Strip whitespace from incoming message in send_message to handle empty/whitespace-only inputs
- Remove unused variable assignment for is_final in send_message non-final branch
2025-06-25 15:54:12 +00:00
retoor ee2ede2e2d fix: invert live-type attribute check and remove expiry timer from chat input
Remove the unused `expiryTimer` property and its related clearTimeout calls from ChatInputComponent. Fix the `liveType` boolean assignment by changing the comparison operator from `!==` to `==` so that the attribute value `"true"` correctly enables live typing behavior. Make `finalizeMessage` synchronous by removing the `await` keyword from the RPC call and the method declaration. Remove the early return guard for empty message values in `sendMessage` to allow empty strings to be sent. Add server-side logic for finalizing messages after a 7-second delay via a new `_finalize_message_task` coroutine, with cancellation support through `_queue_finalize_message`. Re-enable the previously commented-out logic in `send_message` that checks for an existing non-final message from the same user and updates it instead of creating a new one.
2025-06-25 15:31:49 +00:00
retoor ca62f75042 feat: add /leet and /l33t commands with text-to-leet conversion in chat input 2025-06-25 07:10:27 +00:00
retoor 9a5247ca42 fix: replace global eventBus with component subscription and fix file attachment URL generation 2025-06-22 22:43:32 +00:00
BordedDev 328c2d840c fix: replace global eventBus with component subscription and fix file attachment URL building 2025-06-22 22:41:09 +00:00
retoor fe7568336b fix: update signup button link from /register to /register.html
The href attribute of the "Sign Up" anchor element in the index.html template was changed from "/register" to "/register.html" to align with the static HTML routing convention used elsewhere on the site, ensuring consistent URL resolution for the registration page.
2025-06-21 21:48:52 +00:00
retoor 79babcc294 feat: add music.youtube.com to embed hostnames and skip links with data-noembed attribute
- Extend embed_youtube hostname check to include music.youtube.com subdomain
- Skip anchor elements containing data-noembed attribute in embed_url to prevent unwanted embedding
2025-06-19 12:37:17 +00:00
retoor 7d98e58b1c fix: add missing HTML attributes to SAFE_ATTRIBUTES whitelist and fix f-string quote syntax
Add `target`, `rel`, `referrerpolicy`, `controls`, `frameborder`, `allow`, and `allowfullscreen` to the SAFE_ATTRIBUTES set in `src/snek/system/template.py` to prevent stripping of these commonly used attributes during HTML sanitization. Also fix a syntax error in the `embed_url` function where an f-string used double quotes inside a double-quoted string, changing it to single quotes for the fallback description text.
2025-06-19 12:35:40 +00:00
BordedDev e283bf1302 feat: add history_start field to channel model and implement channel clear method with no_save context manager 2025-06-19 07:33:17 +00:00
BordedDev 90971bcb20 chore: remove title from SAFE_ATTRIBUTES set in template.py
The title attribute was removed from the SAFE_ATTRIBUTES dictionary
in src/snek/system/template.py, likely to tighten security by
preventing arbitrary title attributes in sanitized HTML output.
2025-06-19 07:33:05 +00:00
BordedDev 96f0c09cda fix: add target, rel, referrerpolicy, controls, frameborder, allow, allowfullscreen, and title to SAFE_ATTRIBUTES whitelist in template.py
Also fix string interpolation syntax in embed_url function where double quotes were incorrectly nested inside f-string, changing outer quotes from double to single to resolve syntax error.
2025-06-19 07:30:33 +00:00
BordedDev deb7a84082 feat: add get_element_options helper and improve embed dedup with height/width support
Extract repeated meta-tag lookup logic into reusable get_element_options function that accepts fallback priority chain (twitter, og, meta, elem). Enhance embed_url to skip already-processed URLs via attachment key check, and extend embed payload with optional height/width attributes from og:image:width/height or twitter:image:width/height. Refine site name selection to prefer og:site_name over twitter:site, falling back to title tag.
2025-06-19 07:10:31 +00:00
BordedDev 35023a47c4 feat: add history_start filter to channel queries and implement no_save context manager for draft messages
Add `history_start` field to ChannelModel to support filtering messages by creation date, implement `clear` method in ChannelService to set history cutoff, and introduce `no_save` async context manager in Application that patches channel_message save to only update cache. Refactor ChatService to reuse existing non-final messages when updating drafts, and fix UserService search to filter out deleted users.
2025-06-19 06:23:14 +00:00
BordedDev 79528abcb5 feat: add music.youtube.com to allowed hosts and skip links with data-noembed attribute
Extend the embed_youtube function's hostname whitelist to include "music.youtube.com" for YouTube Music URL support. Also update embed_url to skip anchor elements that have a "data-noembed" attribute, preventing unwanted embedding of explicitly excluded links.
2025-06-19 06:11:27 +00:00
retoor 6810b5e998 fix: invert live-type attribute check and force is_final in send_message
- Invert the live-type attribute check from `=== "true"` to `!== "true"` in ChatInputComponent
- Comment out the non-final message lookup logic in RPCView.send_message and force is_final to always be True
2025-06-15 00:30:57 +00:00
retoor fd7fc4220c fix: guard message continuation check behind is_final flag in send_message
Previously, the `send_message` method always queried for an existing non-final
message to update, even when `is_final=True`. This caused unnecessary database
lookups and could incorrectly attempt to update a stale message when sending
a final version. The fix wraps the continuation check inside `if not is_final:`,
so the lookup and update path is only taken when the message is explicitly
marked as non-final.
2025-06-15 00:21:49 +00:00
retoor addb34b6a5 fix: rename shadowed variable in send_message to avoid collision with parameter
The `send_message` method in `RPCView` had a local variable `message` that shadowed the `message` parameter, causing the parameter to be overwritten before being passed to `update_message_text`. Renamed the local variable to `check_message` to preserve the original parameter value for downstream use.
2025-06-15 00:18:45 +00:00
retoor 24c76d980c feat: add event bus for file upload completion and auto-send message with attachment links
- Introduce EventBus class in njet.js with subscribe/publish methods for decoupled component communication
- Replace direct NjetComponent subscription with eventBus.subscribe in chat-input.js for file-uploading events
- Add file-uploads-done event handler that reconstructs message from uploaded files and sends via RPC
- Reset uploadResponses array in FileUploadGrid.reset() to track completed uploads
- Publish file-uploads-done event when all uploads finish, passing array of file metadata
- Send relative_url in WebSocket done message from ChannelAttachmentUploadView for client-side link construction
- Modify RPC send_message to check for existing draft message and update it instead of creating new one
2025-06-15 00:16:08 +00:00
retoor 4b9bb44295 fix: remove debug print statements from BaseMapper retry loop
Removed four debug print calls that were logging function arguments, table name, and
result during the database operation retry loop in the BaseMapper class. These prints
were left over from development and were cluttering the output during normal operation.
2025-06-14 16:36:40 +00:00
retoor 2349e74734 feat: add is_final flag to channel messages and implement finalization loop in maintenance
Add `is_final` field to channel messages with a maintenance loop that toggles messages between final and non-final states. Introduce `send_message` RPC endpoint for creating messages with configurable finality. Remove `_finalize_message_task` and `updated_at` override in chat service, simplifying message finalization logic. Add debug prints to mapper retry loop for database operations.
2025-06-14 15:04:14 +00:00
retoor 47d237e6a7 fix: skip saving non-final channel messages outside no_save context and fix session_get default handling
- In `src/snek/service/chat.py`, wrap non-final channel message saves inside `self.app.no_save()` to prevent persistence of intermediate streaming updates, while final messages save normally.
- In `src/snek/view/rpc.py`, change `session_get` to be async and require a default argument, removing the optional default and fixing the internal lookup to use direct dict access instead of `.get()`.
- Remove unused `datetime` and `time` imports, reorder `_finalize_task` initialization after `_scheduled` list.
2025-06-14 13:42:29 +00:00
retoor f3d18ac1fd fix: replace incorrect app references with self in Application and refactor ChatService message handling 2025-06-14 13:20:04 +00:00
retoor eab362582c feat: add explicit db.commit after each mapper function call in retry loop
The change adds a call to `self.db.commit()` immediately after each successful execution of the wrapped function within the 20-retry loop in `BaseMapper.__call__`. Previously, the function result was returned directly without committing, which could leave pending database transactions unflushed. Now the commit ensures data persistence before returning the result, while still preserving the existing retry-on-exception behavior.
2025-06-14 11:35:41 +00:00
retoor 927691ded4 fix: comment out Content-Security-Policy header assignment in csp_middleware 2025-06-14 11:08:31 +00:00
retoor cbdbd33072 fix: add dynamic origin and nonce to CSP policy for script and style sources 2025-06-14 11:02:53 +00:00
retoor e3870bc112 fix: remove debug print and relax CSP directives for external resources 2025-06-14 10:53:40 +00:00
retoor f134b044c0 fix: expand CSP policy with additional directives and update allowed sources in middleware 2025-06-14 10:42:34 +00:00
retoor 0a1b1f7186 feat: add CSP nonce support and channel clear admin RPC endpoint
- Inject CSP nonce into request context via middleware and pass to templates for script integrity
- Refactor CSP policy to inline nonce generation and update allowed sources (umami, stricter style/img/connect rules)
- Fix editor.js import path from `/njext.ks` to `/njet.js`
- Add `clear` method to ChannelService to reset `history_from` timestamp
- Add `clear_channel` RPC handler for admin users to purge channel history via `ChannelMessageService.clear`
- Insert `asyncio.sleep(0)` in BaseMapper retry loop to yield control during exception handling
2025-06-14 10:34:26 +00:00
retoor e45bdf6f6f feat: add no_save context manager and enable cache for message updates
- Introduce `no_save` async context manager in Application that temporarily replaces the channel_message mapper's save method with a no-op that only updates cache, allowing operations to bypass persistence while tracking suppressed saves
- Enable cache by default in Cache class (set `self.enabled = True`) to support cached reads in service layer
- Add retry logic (up to 20 attempts) in BaseMapper's semaphore-guarded execution to handle transient database failures
- Modify `get` method in BaseService to always filter by `deleted_at is None` and return cached results when available, improving performance for active records
- Update `update_message_text` RPC handler to wrap logic in `no_save` context, increase message age threshold from 3 to 5 seconds, and remove explicit save call to prevent redundant writes
2025-06-14 07:15:08 +00:00
retoor c1ac899cc6 feat: add NjetEditor web component with vim-like editing modes
Implement a custom web component NjetEditor that extends NjetComponent, providing a vim-style editor with normal, insert, visual, and command modes. Includes key bindings for navigation (h,j,k,l,w,b,0,$,gg,G), editing (i,o,a,x,dd,yy,p), visual selection, and a command-line interface triggered by ':'. The editor uses a contenteditable div with dark theme styling and manages internal state for mode, key buffer, yanked/deleted lines, and caret position tracking.
2025-06-14 06:31:37 +00:00
retoor bff96f7737 feat: add history_start filter to channel queries and fix user search deleted_at filter
- Add optional `history_start` field to ChannelModel for filtering messages by creation date
- Update `get_last_message` and `offset` methods in channel_message service to apply history_start filter when set
- Fix user search to filter out deleted users by setting `deleted_at` to None
- Fix typo `cconsole.info` to `console.info` in file-upload-grid.js
- Refactor `run_in_executor` in BaseMapper to conditionally use semaphore and add deleted_at filter to get method
- Add `editor.js` script to app.html template
- Add `clear_channel` RPC endpoint for admin users to delete all messages in a channel
2025-06-14 06:24:48 +00:00
retoor ea20a5cfda feat: enable file upload button and add debug logging for WebSocket attachment transfer
Uncomment click handler in ChatInputComponent to trigger file dialog, add console.info statements in FileUploadGrid for file upload lifecycle events, comment out redundant click listener in UploadButtonElement, and add print debugging in ChannelAttachmentUploadView for binary/text WebSocket messages with async file.tell() call.
2025-06-12 19:59:37 +00:00
retoor b94426bc94 fix: correct fileManager reference from app to app.channel in toggleDrive 2025-06-11 23:41:05 +00:00
retoor 3053fa5e15 feat: add njet.css with fixed-position dialog and window styles centered via transform
Introduce a new stylesheet defining `.njet-dialog` and `.njet-window` classes, both using fixed positioning, 50% centering via translate, 20px padding, and a 33px min-width.
2025-06-11 23:39:22 +00:00
retoor 1dd0e31487 feat: add channel-scoped drive API view and configurable file-manager URL
Introduce ChannelDriveApiView subclassing DriveApiView to serve per-channel drive listings under `/channel/{uid}/drive.json`. Refactor DriveApiView into reusable base methods (get_target, get_download_url) for subclass override. Update file-manager.js to accept a configurable `url` attribute, defaulting to `/drive.json`, and import NjetComponent for future composition. Register the new route in Application and wire channel.html toggleDrive to mount/unmount a file-manager element pointed at the channel drive endpoint.
2025-06-11 23:38:45 +00:00
retoor d87e357bb4 fix: comment out file upload dialog trigger in upload button click handler 2025-06-11 22:09:16 +00:00
retoor f6043649a0 feat: add remove_event_listener method and WebTerminal component with NjetWindow support
- Implement `remove_event_listener` in ContainerService to allow detaching event handlers
- Refactor DumbTerminal into WebTerminal extending NjetComponent with command parsing (clear, help, date)
- Add NjetWindow component and `njet.showWindow` helper for modal window creation
- Integrate WebTerminal into sandbox template and enable log event notifications
- Extend RPC `write_container` with timeout-based communication using session call pattern
2025-06-11 18:20:34 +00:00
retoor 5ec6e01ea7 fix: replace delete channel dialog with upload-in-progress warning in channel.html
- Replace NjetDialog import with njet import in channel.html template
- Change deleteChannel function to show upload-in-progress dialog instead of delete confirmation
- Add dialog.open() call in file-upload-grid.js to ensure dialog is displayed
2025-06-11 15:27:43 +00:00
retoor d35cbd29b0 feat: add WebSocket-based channel attachment upload and fix signal handler removal
- Add ChannelAttachmentUploadView with WebSocket endpoint at /channel/{channel_uid}/attachment.sock for streaming file uploads with progress tracking
- Remove SIGINT/SIGTERM signal handlers that were calling container shutdown
- Fix chat-input.js to open file dialog on upload button click instead of relying on uploaded event
- Fix file-upload-grid.js import path from relative to absolute and move connectedCallback before render to capture channel attribute
- Update ComposeFileManager.shutdown to collect stop tasks asynchronously instead of awaiting each sequentially
2025-06-11 15:13:58 +00:00
retoor 1c0c50a449 fix: correct source path and target directory name in DockerfileUbuntu cp command
The cp command in DockerfileUbuntu had two errors: the source path was incorrectly
specified as './root' instead of '/root', and the target directory was misspelled
as '/opt/bootrap/root' instead of '/opt/bootstrap/root'. This fix ensures the
bootstrap root files are properly copied during image build.
2025-06-10 23:28:35 +00:00
retoor 3bd847d686 feat: add signal handlers for graceful shutdown of docker compose sessions
Add SIGINT and SIGTERM signal handlers to the Application class that trigger
a graceful shutdown of all running docker compose sessions via the new
ContainerService.shutdown() method. The ComposeFileManager.shutdown()
method iterates over running instances and stops each process gracefully
if it is still running.
2025-06-09 10:50:37 +00:00
retoor dc0edf6058 feat: switch container user to root and add welcome bootstrap for dev environment
- Change container user from 'ubuntu' to 'root' in Dockerfile and docker-compose config
- Set working directory to /home/retoor/projects/snek and inject SNEK_UID environment variable
- Add root password setup and copy terminal bootstrap files (entry script, .welcome.txt) into container image
- Refactor compose exec/start methods to capture stdout/stderr via pipes and handle empty output gracefully
- Remove strict returncode check in exec method, replacing with debug print for non-zero exits
2025-06-10 23:21:21 +00:00
retoor dd1c230681 fix: remove redundant encode call in RPCView write_stdin method
The write_stdin method in RPCView was calling content.encode() before passing to the container service, but the service already handles encoding internally. This change removes the duplicate encoding to prevent double-encoding issues when writing to container stdin.
2025-06-10 17:26:52 +00:00
retoor 01675aad93 feat: add write_container method to RPCView for terminal input
Implement async write_container method in RPCView class that validates user login and channel membership, retrieves container name, writes encoded content to container stdin, and returns a placeholder response indicating terminal write completion without response handling.
2025-06-10 17:15:31 +00:00
retoor a10fe338d6 feat: add interactive star signal system with area-based effects and console patching
Implement a comprehensive star signal system in the sandbox HTML template, including area mapping (Center, Corner, Edge, North, South, East, West, All), multiple visual effects (pulseColor, flicker, ripple, spiral, wave, explode, implode, rainbow, gravity, orbit, trail, glitch, aurora, heartbeat, vortex, matrix, firework, nebula, singularity, void, prism, phantom, echo, bloom, storm, eclipse, cascade, inferno, frost, thunder, blossom, tsunami, galaxy, supernova, blackhole, quasar, pulsar, comet, meteor, cluster, voidwalk, stardust, timelapse, dimension, paradox, genesis, oblivion, rebirth, infinity, entropy, chaos, order, balance, harmony, dissonance, resonance, silence, whisper, scream, lullaby, requiem, elegy, hymn, chant, mantra, prayer, curse, blessing, hex, charm, spell, enchantment, bewitchment, enthrallment, mesmerization, hypnotization, trance, dream, nightmare, vision, hallucination, illusion, mirage, fantasy, reality, truth, lie, secret, mystery, enigma, paradox, riddle, puzzle, labyrinth, maze, tangle, knot, web, net, trap, snare, cage, prison, dungeon, tower, castle, kingdom, empire, world, universe, multiverse, omniverse, hyperverse, megaverse, gigaverse, teraverse, petaverse, exaverse, zettaverse, yottaverse, bronzaverse, silververse, goldverse, platinumverse, diamondverse, masterverse, grandmasterverse, challengerverse, eliteverse, legendverse, mythicverse, immortalverse, godverse, titanverse, celestialverse, divineverse, cosmicverse, astralverse, etherealverse, spectralverse, phantomverse, shadowverse, voidverse, abyssverse, chaosverse, orderverse, balanceverse, harmonyverse, dissonanceverse, resonanceverse, silenceverse, whisperverse, screamverse, lullabyverse, requiemverse, elegyverse, hymnverse, chantverse, mantraverse, prayerverse, curseverse, blessingverse, hexverse, charmverse, spellverse, enchantmentverse, bewitchmentverse, enthrallmentverse, mesmerizationverse, hypnotizationverse, tranceverse, dreamverse, nightmareverse, visionverse, hallucinationverse, illusionverse, mirageverse, fantasyverse, realityverse, truthverse, lieverse, secretverse, mysteryverse, enigmaverse, paradoxverse, riddleverse, puzzleverse, labyrinthverse, mazeverse, tangleverse, knotverse, webverse, netverse, trapverse, snareverse, cageverse, prisonverse, dungeonverse, towerverse, castleverse, kingdomverse, empireverse, worldverse, universeverse, multiverseverse, omniverseverse, hyperverseverse, megaverseverse, gigaverseverse, teraverseverse, petaverseverse, exaverseverse, zettaverseverse, yottaverseverse, bronzaverseverse, silververseverse, goldverseverse, platinumverseverse, diamondverseverse, masterverseverse, grandmasterverseverse, challengerverseverse, eliteverseverse, legendverseverse, mythicverseverse, immortalverseverse, godverseverse, titanverseverse, celestialverseverse, divineverseverse, cosmicverseverse, astralverseverse, etherealverseverse, spectralverseverse, phantomverseverse, shadowverseverse, voidverseverse, abyssverseverse, chaosverseverse, orderverseverse, balanceverseverse, harmonyverseverse, dissonanceverseverse, resonanceverseverse, silenceverseverse, whisperverseverse, screamverseverse, lullabyverseverse, requiemverseverse, elegyverseverse, hymnverseverse, chantverseverse, mantraverseverse, prayerverseverse, curseverseverse, blessingverseverse, hexverseverse, charmverseverse, spellverseverse, enchantmentverseverse, bewitchmentverseverse, enthrallmentverseverse, mesmerizationverseverse, hypnotizationverseverse, tranceverseverse, dreamverseverse, nightmareverseverse, visionverseverse, hallucinationverseverse, illusionverseverse, mirageverseverse, fantasyverseverse, realityverseverse, truthverseverse, lieverseverse, secretverseverse, mysteryverseverse, enigmaverseverse, paradoxverseverse, riddleverseverse, puzzleverseverse, labyrinthverseverse, mazeverseverse, tangleverseverse, knotverseverse, webverseverse, netverseverse, trapverseverse, snareverseverse, cageverseverse, prisonverseverse, dungeonverseverse, towerverseverse, castleverseverse, kingdomverseverse, empireverseverse, worldverseverse, universeverseverse, multiverseverseverse, omniverseverseverse, hyperverseverseverse, megaverseverseverse, gigaverseverseverse, teraverseverseverse, petaverseverseverse, exaverseverseverse, zettaverseverseverse, yottaverseverseverse, bronzaverseverseverse, silververseverseverse, goldverseverseverse, platinumverseverseverse, diamondverseverseverse, masterverseverseverse, grandmasterverseverseverse, challengerverseverseverse, eliteverseverseverse, legendverseverseverse, mythicverseverseverse, immortalverseverseverse, godverseverseverse, titanverseverseverse, celestialverseverseverse, divineverseverseverse, cosmicverseverseverse, astralverseverseverse, etherealverseverseverse, spectralverseverseverse, phantomverseverseverse, shadowverseverseverse, voidverseverseverse, abyssverseverseverse, chaosverseverseverse, orderverseverseverse, balanceverseverseverse, harmonyverseverseverse, dissonanceverseverseverse, resonanceverseverseverse, silenceverseverseverse, whisperverseverseverse, screamverseverseverse, lullabyverseverseverse, requiemverseverseverse, elegyverseverseverse, hymnverseverseverse, chantverseverseverse, mantraverseverseverse, prayerverseverseverse, curseverseverseverse, blessingverseverseverse, hexverseverseverse, charmverseverseverse, spellverseverseverse, enchantmentverseverseverse, bewitchmentverseverseverse, enthrallmentverseverseverse, mesmerizationverseverseverse, hypnotizationverseverseverse, tranceverseverseverse, dreamverseverseverse, nightmareverseverseverse, visionverseverseverse, hallucinationverseverseverse, illusionverseverseverse, mirageverseverseverse, fantasyverseverseverse, realityverseverseverse, truthverseverseverse, lieverseverseverse, secretverseverseverse, mysteryverseverseverse, enigmaverseverseverse, paradoxverseverseverse, riddleverseverseverse, puzzleverseverseverse, labyrinthverseverseverse, mazeverseverseverse, tangleverseverseverse, knotverseverseverse, webverseverseverse, netverseverseverse, trapverseverseverse, snareverseverseverse, cageverseverseverse, prisonverseverseverse, dungeonverseverseverse, towerverseverseverse, castleverseverseverse, kingdomverseverseverse, empireverseverseverse, worldverseverseverse, universeverseverseverse, multiverseverseverseverse, omniverseverseverseverse, hyperverseverseverseverse, megaverseverseverseverse, gigaverseverseverseverse, teraverseverseverseverse, petaverseverseverseverse, exaverseverseverseverse, zettaverseverseverseverse, yottaverseverseverseverse, bronzaverseverseverseverse, silververseverseverseverse, goldverseverseverseverse, platinumverseverseverseverse, diamondverseverseverseverse, masterverseverseverseverse, grandmasterverseverseverseverse, challengerverseverseverseverse, eliteverseverseverseverse, legendverseverseverseverse, mythicverseverseverseverse, immortalverseverseverseverse, godverseverseverseverse, titanverseverseverseverse, celestialverseverseverseverse, divineverseverseverseverse, cosmicverseverseverseverse, astralverseverseverseverse, etherealverseverseverseverse, spectralverseverseverseverse, phantomverseverseverseverse, shadowverseverseverseverse, voidverseverseverseverse, abyssverseverseverseverse, chaosverseverseverseverse, orderverseverseverseverse, balanceverseverseverseverse, harmonyverseverseverseverse, dissonanceverseverseverseverse, resonanceverseverseverseverse, silenceverseverseverseverse, whisperverseverseverseverse, screamverseverseverseverse, lullabyverseverseverseverse, requiemverseverseverseverse, elegyverseverseverseverse, hymnverseverseverseverse, chantverseverseverseverse, mantraverseverseverseverse, prayerverseverseverseverse, curseverseverseverseverse, blessingverseverseverseverse, hexverseverseverseverse, charmverseverseverseverse, spellverseverseverseverse, enchantmentverseverseverseverse, bewitchmentverseverseverseverse, enthrallmentverseverseverseverse, mesmerizationverseverseverseverse, hypnotizationverseverseverseverse, tranceverseverseverseverse, dreamverseverseverseverse, nightmareverseverseverseverse, visionverseverseverseverse, hallucinationverseverseverseverse, illusionverseverseverseverse, mirageverseverseverseverse, fantasyverseverseverseverse, realityverseverseverseverse, truthverseverseverseverse, lieverseverseverseverse, secretverseverseverseverse, mysteryverseverseverseverse, enigmaverseverseverseverse, paradoxverseverseverseverse, riddleverseverseverseverse, puzzleverseverseverseverse, labyrinthverseverseverseverse, mazeverseverseverseverse, tangleverseverseverseverse, knotverseverseverseverse, webverseverseverseverse, netverseverseverseverse, trapverseverseverseverse, snareverseverseverseverse, cageverseverseverseverse, prisonverseverseverseverse, dungeonverseverseverseverse, towerverseverseverseverse, castleverseverseverseverse, kingdomverseverseverseverse, empireverseverseverseverse, worldverseverseverseverse, universeverseverseverseverse, multiverseverseverseverseverse, omniverseverseverseverseverse, hyperverseverseverseverseverse, megaverseverseverseverseverse, gigaverseverseverseverseverse, teraverseverseverseverseverse, petaverseverseverseverseverse, exaverseverseverseverseverse, zettaverseverseverseverseverse, yottaverseverseverseverseverse, bronzaverseverseverseverseverse, silververseverseverseverseverse, goldverseverseverseverseverse, platinumverseverseverseverseverse, diamondverseverseverseverseverse, masterverseverseverseverseverse, grandmasterverseverseverseverseverse, challengerverseverseverseverseverse, eliteverseverseverseverseverse, legendverseverseverseverseverse, mythicverseverseverseverseverse, immortalverseverseverseverseverse, godverseverseverseverseverse, titanverseverseverseverseverse, celestialverseverseverseverseverse, divineverseverseverseverseverse, cosmicverseverseverseverseverse, astralverseverseverseverseverse, etherealverseverseverseverseverse, spectralverseverseverseverseverse, phantomverseverseverseverseverse, shadowverseverseverseverseverse, voidverseverseverseverseverse, abyssverseverseverseverseverse, chaosverseverseverseverseverse, orderverseverseverseverseverse, balanceverseverseverseverseverse, harmonyverseverseverseverseverse, dissonanceverseverseverseverseverse, resonanceverseverseverseverseverse, silenceverseverseverseverseverse, whisperverseverseverseverseverse, screamverseverseverseverseverse, lullabyverseverseverseverseverse, requiemverseverseverseverseverse, elegyverseverseverseverseverse, hymnverseverseverseverseverse, chantverseverseverseverseverse, mantraverseverseverseverseverse, prayerverseverseverseverseverse, curseverseverseverseverseverse, blessingverseverseverseverseverse, hexverseverseverseverseverse, charmverseverseverseverseverse, spellverseverseverseverseverse, enchantmentverseverseverseverseverse, bewitchmentverseverseverseverseverse, enthrallmentverseverseverseverseverse, mesmerizationverseverseverseverseverse, hypnotizationverseverseverseverseverse, tranceverseverseverseverseverse, dreamverseverseverseverseverse, nightmareverseverseverseverseverse, visionverseverseverseverseverse, hallucinationverseverseverseverseverse, illusionverseverseverseverseverse, mirageverseverseverseverseverse, fantasyverseverseverseverseverse, realityverseverseverseverseverse, truthverseverseverseverseverse, lieverseverseverseverseverse, secretverseverseverseverseverse, mysteryverseverseverseverseverse, enigmaverseverseverseverseverse, paradoxverseverseverseverseverse, riddleverseverseverseverseverse, puzzleverseverseverseverseverse, labyrinthverseverseverseverseverse, mazeverseverseverseverseverse, tangleverseverseverseverseverse, knotverseverseverseverseverse, webverseverseverseverseverse, netverseverseverseverseverse, trapverseverseverseverseverse, snareverseverseverseverseverse, cageverseverseverseverseverse, prisonverseverseverseverseverse, dungeonverseverseverseverseverse, towerverseverseverseverseverse, castleverseverseverseverseverse, kingdomverseverseverseverseverse, empireverseverseverseverseverse, worldverseverseverseverseverse, universeverseverseverseverseverse, multiverseverseverseverseverseverse, omniverseverseverseverseverseverse, hyperverseverseverseverseverseverse, megaverseverseverseverseverseverse, gigaverseverseverseverseverseverse, teraverseverseverseverseverseverse, petaverseverseverseverseverseverse, exaverseverseverseverseverseverse, zettaverseverseverseverseverseverse, yottaverseverseverseverseverseverse, bronzaverseverseverseverseverseverse, silververseverseverseverseverseverse, goldverseverseverseverseverseverse, platinumverseverseverseverseverseverse, diamondverseverseverseverseverseverse, masterverseverseverseverseverseverse, grandmasterverseverseverseverseverseverse, challengerverseverseverseverseverseverse, eliteverseverseverseverseverseverse, legendverseverseverseverseverseverse, mythicverseverseverseverseverseverse, immortalverseverseverseverseverseverse, godverseverseverseverseverseverse, titanverseverseverseverseverseverse, celestialverseverseverseverseverseverse, divineverseverseverseverseverseverse, cosmicverseverseverseverseverseverse, astralverseverseverseverseverseverse, etherealverseverseverseverseverseverse, spectralverseverseverseverseverseverse, phantomverseverseverseverseverseverse, shadowverseverseverseverseverseverse, voidverseverseverseverseverseverse, abyssverseverseverseverseverseverse, chaosverseverseverseverseverseverse, orderverseverseverseverseverseverse, balanceverseverseverseverseverseverse, harmonyverseverseverseverseverseverse, dissonanceverseverseverseverseverseverse, resonanceverseverseverseverseverseverse, silenceverseverseverseverseverseverse, whisperverseverseverseverseverseverse, screamverseverseverseverseverseverse, lullabyverseverseverseverseverseverse, requiemverseverseverseverseverseverse, elegyverseverseverseverseverseverse, hymnverseverseverseverseverseverse, chantverseverseverseverseverseverse, mantraverseverseverseverseverseverse, prayerverseverseverseverseverseverse, curseverseverseverseverseverseverse, blessingverseverseverseverseverseverse, hexverseverseverseverseverseverse, charmverseverseverseverseverseverse, spellverseverseverseverseverseverse, enchantmentverseverseverseverseverseverse, bewitchmentverseverseverseverseverseverse, enthrallmentverseverseverseverseverseverse, mesmerizationverseverseverseverseverseverse, hypnotizationverseverseverseverseverseverse, tranceverseverseverseverseverseverse, dreamverseverseverseverseverseverse, nightmareverseverseverseverseverseverse, visionverseverseverseverseverseverse, hallucinationverseverseverseverseverseverse, illusionverseverseverseverseverseverse, mirageverseverseverseverseverseverse, fantasyverseverseverseverseverseverse, realityverseverseverseverseverseverse, truthverseverseverseverseverseverse, lieverseverseverseverseverseverse, secretverseverseverseverseverseverse, mysteryverseverseverseverseverseverse, enigmaverseverseverseverseverseverse, paradoxverseverseverseverseverseverse, riddleverseverseverseverseverseverse, puzzleverseverseverseverseverseverse, labyrinthverseverseverseverseverseverse, mazeverseverseverseverseverseverse, tangleverseverseverseverseverseverse, knotverseverseverseverseverseverse, webverseverseverseverseverseverse, netverseverseverseverseverseverse, trapverseverseverseverseverseverse, snareverseverseverseverseverseverse, cageverseverseverseverseverseverse, prisonverseverseverseverseverseverse, dungeonverseverseverseverseverseverse, towerverseverseverseverseverseverse, castleverseverseverseverseverseverse, kingdomverseverseverseverseverseverse, empireverseverseverseverseverseverse, worldverseverseverseverseverseverse, universeverseverseverseverseverseverse, multiverseverseverseverseverseverseverse, omniverseverseverseverseverseverseverse, hyperverseverseverseverseverseverseverse, megaverseverseverseverseverseverseverse, gigaverseverseverseverseverseverseverse, teraverseverseverseverseverseverseverse, petaverseverseverseverseverseverseverse, exaverseverseverseverseverseverseverse, zettaverseverseverseverseverseverseverse, yottaverseverseverseverseverseverseverse, bronzaverseverseverseverseverseverseverse, silververseverseverseverseverseverseverse, goldverseverseverseverseverseverseverse, platinumverseverseverseverseverseverseverse, diamondverseverseverseverseverseverseverse, masterverseverseverseverseverseverseverse, grandmasterverseverseverseverseverseverseverse, challengerverseverseverseverseverseverseverse, eliteverseverseverseverseverseverseverse, legendverseverseverseverseverseverseverse, mythicverseverseverseverseverseverseverse, immortalverseverseverseverseverseverseverse, godverseverseverseverseverseverseverse, titanverseverseverseverseverseverseverse, celestialverseverseverseverseverseverseverse, divineverseverseverseverseverseverseverse, cosmicverseverseverseverseverseverseverse, astralverseverseverseverseverseverseverse, etherealverseverseverseverseverseverseverse, spectralverseverseverseverseverseverseverse, phantomverseverseverseverseverseverseverse, shadowverseverseverseverseverseverseverse, voidverseverseverseverseverseverseverse, abyssverseverseverseverseverseverseverse, chaosverseverseverseverseverseverseverse, orderverseverseverseverseverseverseverse, balanceverseverseverseverseverseverseverse, harmonyverseverseverseverseverseverseverse, dissonanceverseverseverseverseverseverseverse, resonanceverseverseverseverseverseverseverse, silenceverseverseverseverseverseverseverse, whisperverseverseverseverseverseverseverse, screamverseverseverseverseverseverseverse, lullabyverseverseverseverseverseverseverse, requiemverseverseverseverseverseverseverse, elegyverseverseverseverseverseverseverse, hymnverseverseverseverseverseverseverse, chantverseverseverseverseverseverseverse, mantraverseverseverseverseverseverseverse, prayerverseverseverseverseverseverseverse, curseverseverseverseverseverseverseverse, blessingverseverseverseverseverseverseverse, hexverseverseverseverseverseverseverse, charmverseverseverseverseverseverseverse, spellverseverseverseverseverseverseverse, enchantmentverseverseverseverseverseverseverse, bewitchmentverseverseverseverseverseverseverse, enthrallmentverseverseverseverseverseverseverse, mesmerizationverseverseverseverseverseverseverse, hypnotizationverseverseverseverseverseverseverse, tranceverseverseverseverseverseverseverse, dreamverseverseverseverseverseverseverse, nightmareverseverseverseverseverseverseverse, visionverseverseverseverseverseverseverse, hallucinationverseverseverseverseverseverseverse, illusionverseverseverseverseverseverseverse, mirageverseverseverseverseverseverseverse, fantasyverseverseverseverseverseverseverse, realityverseverseverseverseverseverseverse, truthverseverseverseverseverseverseverse, lieverseverseverseverseverseverseverse, secretverseverseverseverseverseverseverse, mysteryverseverseverseverseverseverseverse, enigmaverseverseverseverseverseverseverse, paradoxverseverseverseverseverseverseverse, riddleverseverseverseverseverseverseverse, puzzleverseverseverseverseverseverseverse, labyrinthverseverseverseverseverseverseverse, mazeverseverseverseverseverseverseverse, tangleverseverseverseverseverseverseverse, knotverseverseverseverseverseverseverse, webverseverseverseverseverseverseverse, netverseverseverseverseverseverseverse, trapverseverseverseverseverseverseverse, snareverseverseverseverseverseverseverse, cageverseverseverseverseverseverseverse, prisonverseverseverseverseverseverseverse, dungeonverseverseverseverseverseverseverse, towerverseverseverseverseverseverseverse, castleverseverseverseverseverseverseverse, kingdomverseverseverseverseverseverseverse, empireverseverseverseverseverseverseverse, worldverseverseverseverseverseverseverse, universeverseverseverseverseverseverseverse, multiverseverseverseverseverseverseverseverse, omniverseverseverseverseverseverseverseverse, hyperverseverseverseverseverseverseverseverse, megaverseverseverseverseverseverseverseverse, gigaverseverseverseverseverseverseverseverse, teraverseverseverseverseverseverseverseverse, petaverseverseverseverseverseverseverseverse, exaverseverseverseverseverseverseverseverse, zettaverseverseverseverseverseverseverseverse, yottaverseverseverseverseverseverseverseverse, bronzaverseverseverseverseverseverseverseverse, silververseverseverseverseverseverseverseverse, goldverseverseverseverseverseverseverseverse, platinumverseverseverseverseverseverseverseverse, diamondverseverseverseverseverseverseverseverse, masterverseverseverseverseverseverseverseverse, grandmasterverseverseverseverseverseverseverseverse, challengerverseverseverseverseverseverseverseverse, eliteverseverseverseverseverseverseverseverse, legendverseverseverseverseverseverseverseverse, mythicverseverseverseverseverseverseverseverse, immortalverseverseverseverseverseverseverseverse, godverseverseverseverseverseverseverseverse, titanverseverseverseverseverseverseverseverse, celestialverseverseverseverseverseverseverseverse, divineverseverseverseverseverseverseverseverse, cosmicverseverseverseverseverseverseverseverse, astralverseverseverseverseverseverseverseverse, etherealverseverseverseverseverseverseverseverse, spectralverseverseverseverseverseverseverseverse, phantomverseverseverseverseverseverseverseverse, shadowverseverseverseverseverseverseverseverse, voidverseverseverseverseverseverseverseverse, abyssverseverseverseverseverseverseverseverse, chaosverseverseverseverseverseverseverseverse, orderverseverseverseverseverseverseverseverse, balanceverseverseverseverseverseverseverseverse, harmonyverseverseverseverseverseverseverseverse, dissonanceverseverseverseverseverseverseverseverse, resonanceverseverseverseverseverseverseverseverse, silenceverseverseverseverseverseverseverseverse, whisperverseverseverseverseverseverseverseverse, screamverseverseverseverseverseverseverseverse, lullabyverseverseverseverseverseverseverseverse, requiemverseverseverseverseverseverseverseverse, elegyverseverseverseverseverseverseverseverse, hymnverseverseverseverseverseverseverseverse, chantverseverseverseverseverseverseverseverse, mantraverseverseverseverseverseverseverseverse, prayerverseverseverseverseverseverseverseverse, curseverseverseverseverseverseverseverseverse, blessingverseverseverseverseverseverseverseverse, hexverseverseverseverseverseverseverseverse, charmverseverseverseverseverseverseverseverse, spellverseverseverseverseverseverseverseverse, enchantmentverseverseverseverseverseverseverseverse, bewitchmentverseverseverseverseverseverseverseverse, enthrallmentverseverseverseverseverseverseverseverse, mesmerizationverseverseverseverseverseverseverseverse, hypnotizationverseverseverseverseverseverseverseverse, tranceverseverseverseverseverseverseverseverse, dreamverseverseverseverseverseverseverseverse, nightmareverseverseverseverseverseverseverseverse, visionverseverseverseverseverseverseverseverse, hallucinationverseverseverseverseverseverseverseverse, illusionverseverseverseverseverseverseverseverse, mirageverseverseverseverseverseverseverseverse, fantasyverseverseverseverseverseverseverseverse, realityverseverseverseverseverseverseverseverse, truthverseverseverseverseverseverseverseverse, lieverseverseverseverseverseverseverseverse, secretverseverseverseverseverseverseverseverse, mysteryverseverseverseverseverseverseverseverse, enigmaverseverseverseverseverseverseverseverse, paradoxverseverseverseverseverseverseverseverse, riddleverseverseverseverseverseverseverseverse, puzzleverseverseverseverseverseverseverseverse, labyrinthverseverseverseverseverseverseverseverse, mazeverseverseverseverseverseverseverseverse, tangleverseverseverseverseverseverseverseverse, knotverseverseverseverseverseverseverseverse, webverseverseverseverseverseverseverseverse, netverseverseverseverseverseverseverseverse, trapverseverseverseverseverseverseverseverse, snareverseverseverseverseverseverseverseverse, cageverseverseverseverseverseverseverseverse, prisonverseverseverseverseverseverseverseverse, dungeonverseverseverseverseverseverseverseverse, towerverseverseverseverseverseverseverseverse, castleverseverseverseverseverseverseverseverse, kingdomverseverseverseverseverseverseverseverse, empireverseverseverseverseverseverseverseverse, worldverseverseverseverseverseverseverseverse, universeverseverseverseverseverseverseverseverse, multiverseverseverseverseverseverseverseverseverse, omniverseverseverseverseverseverseverseverseverse, hyperverseverseverseverseverseverseverseverseverse, megaverseverseverseverseverseverseverseverseverse, gigaverseverseverseverseverseverseverseverseverse, teraverseverseverseverseverseverseverseverseverse, petaverseverseverseverseverseverseverseverseverse, exaverseverseverseverseverseverseverseverseverse, zettaverseverseverseverseverseverseverseverseverse, yottaverseverseverseverseverseverseverseverseverse, bronzaverseverseverseverseverseverseverseverseverse, silververseverseverseverseverseverseverseverseverse, goldverseverseverseverseverseverseverseverseverse, platinumverseverseverseverseverseverseverseverseverse, diamondverseverseverseverseverseverseverseverseverse, masterverseverseverseverseverseverseverseverseverse, grandmasterverseverseverseverseverseverseverseverseverse, challengerverseverseverseverseverseverseverseverseverse, eliteverseverseverseverseverseverseverseverseverse, legendverseverseverseverseverseverseverseverseverse, mythicverseverseverseverseverseverseverseverseverse, immortalverseverseverseverseverseverseverseverseverse, godverseverseverseverseverseverseverseverseverse, titanverseverseverseverseverseverseverseverseverse, celestialverseverseverseverseverseverseverseverseverse, divineverseverseverseverseverseverseverseverseverse, cosmicverseverseverseverseverseverseverseverseverse, astralverseverseverseverseverseverseverseverseverse, etherealverseverseverseverseverseverseverseverseverse, spectralverseverseverseverseverseverseverseverseverse, phantomverseverseverseverseverseverseverseverseverse, shadowverseverseverseverseverseverseverseverseverse, voidverseverseverseverseverseverseverseverseverse, abyssverseverseverseverseverseverseverseverseverse, chaosverseverseverseverseverseverseverseverseverse, orderverseverseverseverseverseverseverseverseverse, balanceverseverseverseverseverseverseverseverseverse, harmonyverseverseverseverseverseverseverseverseverse, dissonanceverseverseverseverseverseverseverseverseverse, resonanceverseverseverseverseverseverseverseverseverse, silenceverseverseverseverseverseverseverseverseverse, whisperverseverseverseverseverseverseverseverseverse, screamverseverseverseverseverseverseverseverseverse, lullabyverseverseverseverseverseverseverseverseverse, requiemverseverseverseverseverseverseverseverseverse, elegyverseverseverseverseverseverseverseverseverse, hymnverseverseverseverseverseverseverseverseverse, chantverseverseverseverseverseverseverseverseverse, mantraverseverseverseverseverseverseverseverseverse, prayerverseverseverseverseverseverseverseverseverse, curseverseverseverseverseverseverseverseverseverse, blessingverseverseverseverseverseverseverseverseverse, hexverseverseverseverseverseverseverseverseverse, charmverseverseverseverseverseverseverseverseverse, spellverseverseverseverseverseverseverseverseverse, enchantmentverseverseverseverseverseverseverseverseverse, bewitchmentverseverseverseverseverseverseverseverseverse, enthrallmentverseverseverseverseverseverseverseverseverse, mesmerizationverseverseverseverseverseverseverseverseverse, hypnotizationverseverseverseverseverseverseverseverseverse, tranceverseverseverseverseverseverseverseverseverse, dreamverseverseverseverseverseverseverseverseverse, nightmareverseverseverseverseverseverseverseverseverse, visionverseverseverseverseverseverseverseverseverse, hallucinationverseverseverseverseverseverseverseverseverse, illusionverseverseverseverseverseverseverseverseverse, mirageverseverseverseverseverseverseverseverseverse, fantasyverseverseverseverseverseverseverseverseverse, realityverseverseverseverseverseverseverseverseverse, truthverseverseverseverseverseverseverseverseverse, lieverseverseverseverseverseverseverseverseverse, secretverseverseverseverseverseverseverseverseverse, mysteryverseverseverseverseverseverseverseverseverse, enigmaverseverseverseverseverseverseverseverseverse, paradoxverseverseverseverseverseverseverseverseverse, riddleverseverseverseverseverseverseverseverseverse, puzzleverseverseverseverseverseverseverseverseverse, labyrinthverseverseverseverseverseverseverseverseverse, mazeverseverseverseverseverseverseverseverseverse, tangleverseverseverseverseverseverseverseverseverse, knotverseverseverseverseverseverseverseverseverse, webverseverseverseverseverseverseverseverseverse, netverseverseverseverseverseverseverseverseverse, trapverseverseverseverseverseverseverseverseverse, snareverseverseverseverseverseverseverseverseverse, cageverseverseverseverseverseverseverseverseverse, prisonverseverseverseverseverseverseverseverseverse, dungeonverseverseverseverseverseverseverseverseverse, towerverseverseverseverseverseverseverseverseverse, castleverseverseverseverseverseverseverseverseverse, kingdomverseverseverseverseverseverseverseverseverse, empireverseverseverseverseverseverseverseverseverse, worldverseverseverseverseverseverseverseverseverse, universeverseverseverseverseverseverseverseverseverse, multiverseverseverseverseverseverseverseverseverseverse, omniverseverseverseverseverseverseverseverseverseverse, hyperverseverseverseverseverseverseverseverseverseverse, megaverseverseverseverseverseverseverseverseverseverse, gigaverseverseverseverseverseverseverseverseverseverse, teraverseverseverseverseverseverseverseverseverseverse, petaverseverseverseverseverseverseverseverseverseverse, exaverseverseverseverseverseverseverseverseverseverse, zettaverseverseverseverseverseverseverseverseverseverse, yottaverseverseverseverseverseverseverseverseverseverse, bronzaverseverseverseverseverseverseverseverseverseverse, silververseverseverseverseverseverseverseverseverseverse, goldverseverseverseverseverseverseverseverseverseverse, platinumverseverseverseverseverseverseverseverseverseverse, diamondverseverseverseverseverseverseverseverseverseverse, masterverseverseverseverseverseverseverseverseverseverse, grandmasterverseverseverseverseverseverseverseverseverseverse, challengerverseverseverseverseverseverseverseverseverseverse, eliteverseverseverseverseverseverseverseverseverseverse, legendverseverseverseverseverseverseverseverseverseverse, mythicverseverseverseverseverseverseverseverseverseverse, immortalverseverseverseverseverseverseverseverseverseverse, godverseverseverseverseverseverseverseverseverseverse, titanverseverseverseverseverseverseverseverseverseverse, celestialverseverseverseverseverseverseverseverseverseverse, divineverseverseverseverseverseverseverseverseverseverse, cosmicverseverseverseverseverseverseverseverseverseverse, astralverseverseverseverseverseverseverseverseverseverse, etherealverseverseverseverseverseverseverseverseverseverse, spectralverseverseverseverseverseverseverseverseverseverse, phantomverseverseverseverseverseverseverseverseverseverse, shadowverseverseverseverseverseverseverseverseverseverse, voidverseverseverseverseverseverseverseverseverseverse, abyssverseverseverseverseverseverseverseverseverseverse, chaosverseverseverseverseverseverseverseverseverseverse, orderverseverseverseverseverseverseverseverseverseverse, balanceverseverseverseverseverseverseverseverseverseverse, harmonyverseverseverseverseverseverseverseverseverseverse, dissonanceverseverseverseverseverseverseverseverseverseverse, resonanceverseverseverseverseverseverseverseverseverseverse, silenceverseverseverseverseverseverseverseverseverseverse, whisperverseverseverseverseverseverseverseverseverseverse, screamverseverseverseverseverseverseverseverseverseverse, lullabyverseverseverseverseverseverseverseverseverseverse, requiemverseverseverseverseverseverseverseverseverseverse, elegyverseverseverseverseverseverseverseverseverseverse, hymnverseverseverseverseverseverseverseverseverseverse, chantverseverseverseverseverseverseverseverseverseverse, mantraverseverseverseverseverseverseverseverseverseverse, prayerverseverseverseverseverseverseverseverseverseverse, curseverseverseverseverseverseverseverseverseverseverse, blessingverseverseverseverseverseverseverseverseverseverse, hexverseverseverseverseverseverseverseverseverseverse, charmverseverseverseverseverseverseverseverseverseverse, spellverseverseverseverseverseverseverseverseverseverse, enchantmentverseverseverseverseverseverseverseverseverseverse, bewitchmentverseverseverseverseverseverseverseverseverseverse, enthrallmentverseverseverseverseverseverseverseverseverseverse, mesmerizationverseverseverseverseverseverseverseverseverseverse, hypnotizationverseverseverseverseverseverseverseverseverseverse, tranceverseverseverseverseverseverseverseverseverseverse, dreamverseverseverseverseverseverseverseverseverseverse, nightmareverseverseverseverseverseverseverseverseverseverse, visionverseverseverseverseverseverseverseverseverseverse, hallucinationverseverseverseverseverseverseverseverseverseverse, illusionverseverseverseverseverseverseverseverseverseverse, mirageverseverseverseverseverseverseverseverseverseverse, fantasyverseverseverseverseverseverseverseverseverseverse, realityverseverseverseverseverseverseverseverseverseverse, truthverseverseverseverseverseverseverseverseverseverse, lieverseverseverseverseverseverseverseverseverseverse, secretverseverseverseverseverseverseverseverseverseverse, mysteryverseverseverseverseverseverseverseverseverseverse, enigmaverseverseverseverseverseverseverseverseverseverse, paradoxverseverseverseverseverseverseverseverseverseverse, riddleverseverseverseverseverseverseverseverseverseverse, puzzleverseverseverseverseverseverseverseverseverseverse, labyrinthverseverseverseverseverseverseverseverseverseverse, mazeverseverseverseverseverseverseverseverseverseverse, tangleverseverseverseverseverseverseverseverseverseverse, knotverseverseverseverseverseverseverseverseverseverse, webverseverseverseverseverseverseverseverseverseverse, netverseverseverseverseverseverseverseverseverseverse, trapverseverseverseverseverseverseverseverseverseverse, snareverseverseverseverseverseverseverseverseverseverse, cageverseverseverseverseverseverseverseverseverseverse, prisonverseverseverseverseverseverseverseverseverseverse, dungeonverseverseverseverseverseverseverseverseverseverse, towerverseverseverseverseverseverseverseverseverseverse, castleverseverseverseverseverseverseverseverseverseverse, kingdomverseverseverseverseverseverseverseverseverseverse, empireverseverseverseverseverseverseverseverseverseverse, worldverseverseverseverseverseverseverseverseverseverse, universeverseverseverseverseverseverseverseverseverseverse, multiverseverseverseverseverseverseverseverseverseverseverse, omniverseverseverseverseverseverseverseverseverseverseverse, hyperverseverseverseverseverseverseverseverseverseverseverse, megaverseverseverseverseverseverseverseverseverseverseverse, gigaverseverseverseverseverseverseverseverseverseverseverse, teraverseverseverseverseverseverseverseverseverseverseverse, petaverseverseverseverseverseverseverseverseverseverseverse, exaverseverseverseverseverseverseverseverseverseverseverse, zettaverseverseverseverseverseverseverseverseverseverseverse, yottaverseverseverseverseverseverseverseverseverseverseverse, bronzaverseverseverseverseverseverseverseverseverseverseverse, silververseverseverseverseverseverseverseverseverseverseverse, goldverseverseverseverseverseverseverseverseverseverseverse, platinumverseverseverseverseverseverseverseverseverseverseverse, diamondverseverseverseverseverseverseverseverseverseverseverse, masterverseverseverseverseverseverseverseverseverseverseverse, grandmasterverseverseverseverseverseverseverseverseverseverseverse, challengerverseverseverseverseverseverseverseverseverseverseverse, eliteverseverseverseverseverseverseverseverseverseverseverse, legendverseverseverseverseverseverseverseverseverseverseverse, mythicverseverseverseverseverseverseverseverseverseverseverse, immortalverseverseverseverseverseverseverseverseverseverseverse, godverseverseverseverseverseverseverseverseverseverseverse, titanverseverseverseverseverseverseverseverseverseverseverse, celestialverseverseverseverseverseverseverseverseverseverseverse, divineverseverseverseverseverseverseverseverseverseverseverse, cosmicverseverseverseverseverseverseverseverseverseverseverse, astralverseverseverseverseverseverseverseverseverseverseverse, etherealverseverseverseverseverseverseverseverseverseverseverse, spectralverseverseverseverseverseverseverseverseverseverseverse, phantomverseverseverseverseverseverseverseverseverseverseverse, shadowverseverseverseverseverseverseverseverseverseverseverse, voidverseverseverseverseverseverseverseverseverseverseverse, abyssverseverseverseverseverseverseverseverseverseverseverse, chaosverseverseverseverseverseverseverseverseverseverseverse, orderverseverseverseverseverseverseverseverseverseverseverse, balanceverseverseverseverseverseverseverseverseverseverseverse, harmonyverseverseverseverseverseverseverseverseverseverseverse, dissonanceverseverseverseverseverseverseverseverseverseverseverse, resonanceverseverseverseverseverseverseverseverseverseverseverse
2025-06-10 16:25:21 +00:00
retoor 4f8ff07c7b fix: change container volume mount path from /root to /home/ubuntu and set compose user to ubuntu 2025-06-10 15:04:50 +00:00
retoor 46f6fe1874 fix: update container home volume mount path from /home/ubuntu to /root
The container service's volume mount configuration for the home folder was pointing to an incorrect base path. Changed the hardcoded path from "/home/ubuntu" to "/root" to match the actual container filesystem layout, ensuring proper volume binding when creating container instances.
2025-06-10 14:51:40 +00:00
retoor 969ad574b7 fix: add retry loop with error handling for docker compose stop communication 2025-06-10 14:44:01 +00:00
retoor b4139a8d21 feat: replace image field with build context in ComposeFileManager service dict 2025-06-10 14:28:30 +00:00
retoor 299c469e7b fix: comment out refresh call and construct WebSocket URL dynamically from location protocol and host 2025-06-10 13:59:41 +00:00
retoor 0ced03c6c7 feat: increase terminal width to 50% and shift chat area to 60% left offset
Adjust the terminal container width from 40% to 50% and the chat area left position from 50% to 60% to improve layout proportions and visual balance between the two panels.
2025-06-10 13:44:39 +00:00
retoor a4cad6b79c fix: enable container dialog opening by uncommenting openWithStatus call 2025-06-10 13:31:31 +00:00
retoor 56a02b62cf feat: add fit method to Container and implement development mode layout toggle
Add a `fit()` method to the Container class that invokes the xterm.js fit addon to resize the terminal to its parent element. In the app template, introduce a `toggleDevelopmentMode()` function that hides the header, repositions the sidebar to a fixed 10% width on the left, allocates 40% width to both the terminal and chat area side by side, calls `container.fit()` for proper terminal sizing, and renders a "H4x0r 1337" word on the star field. Also refactor the `/container` command in web.html to remove redundant container initialization logic, leaving only a placeholder comment for the dialog open call.
2025-06-10 13:31:09 +00:00
retoor 50cef744e8 fix: add ctrl+. shortcut to toggle terminal visibility in app.html
Add keyboard event handler for Ctrl+. key combination that toggles the
hidden state of the terminal element when window.container is available,
providing a quick way to show or hide the terminal interface during runtime.
2025-06-10 11:39:35 +00:00
retoor a059f6fd5c fix: add missing 'new' keyword when instantiating Container in app.html template 2025-06-10 11:37:34 +00:00
retoor fad0ef9d39 fix: remove broken toggleContainer and Ctrl+. handler from app.html template
Remove the non-functional `toggleContainer` method and its associated Ctrl+. keyboard shortcut that referenced undefined variables. Relocate the channel initialization block after the keydown listener to fix execution order dependency. Simplify `getContainer` to return a new Container instance directly without caching.
2025-06-10 11:18:26 +00:00
retoor b1dc595373 fix: expose getContainer globally and fix container reference in toggleContainer 2025-06-10 11:04:33 +00:00
retoor 382d34d160 feat: add toggleable container terminal with Ctrl+. shortcut in app template
Implement a toggleContainer function that shows/hides the container terminal element, and bind it to Ctrl+. keypress. Refactor getContainer to cache the Container instance and expose it globally. Move channel initialization block earlier in the script to ensure container is available before key handlers. Change container start response from string to bytes in ContainerView.
2025-06-10 11:00:59 +00:00
retoor f73bcc65a1 feat: add RestClient class with request/response interceptors and fetch-based HTTP client
Implement a full-featured HTTP client class in njet.js supporting configurable base URL, default headers, request/response interceptor chains, automatic JSON serialization/deserialization, and response header parsing. The class provides a core request method that merges configuration, applies interceptors via promise chaining, and handles both successful and error responses with structured result objects.
2025-06-10 09:36:09 +00:00
retoor f1ccb26a79 feat: add file upload grid component with progress tracking and channel deletion dialog
- Import Njet and NjetComponent in app.js and chat-input.js, extend ChatInputComponent from NjetComponent
- Add FileUploadGrid component with CSS styling, progress bars, and file dialog handling
- Integrate file upload grid into chat input with subscribe-based visibility toggling
- Include new CSS and JS module scripts in app.html template
- Add channel.html template with NjetDialog for delete channel confirmation
2025-06-10 09:28:46 +00:00
retoor db3b6afde2 fix: gracefully handle missing pty module and register HEIF opener for image attachments 2025-06-10 03:40:52 +00:00
BordedDev 302ac459c4 feat: register HEIF opener for image handling and gracefully handle missing pty module on terminal import 2025-06-09 20:03:36 +00:00
retoor 582cab6357 feat: add MIT-licensed load_env utility to parse .env files into os.environ
Implement a new `load_env` function in `src/snek/__init__.py` that reads key-value pairs from a specified environment file (defaulting to `.env`), strips whitespace, skips empty lines and comments, and sets each variable in the current process environment via `os.environ`. Includes full MIT license header with author attribution and comprehensive docstring with error documentation for `FileNotFoundError` and `IOError`.
2025-06-09 13:04:52 +00:00
retoor d60af2a879 feat: add export and statistics CLI commands for channel message dumping and database stats 2025-06-09 04:55:37 +00:00
retoor 36433daee9 feat: add StatisticsService for database table analysis and Shell for IPython maintenance
Implement StatisticsService with PRAGMA table_info and COUNT queries across 14 tables, printing column stats including distinct counts and numeric min/max/avg. Add Shell class that initializes Application from a SQLite path and exposes maintenance coroutines via IPython user namespace.
2025-06-09 04:47:50 +00:00
retoor 28cb6f2513 feat: add maintenance commands and statistics service with container creation on missing channels
- Add `maintenance` CLI command that runs container and channel message maintenance routines
- Implement `ContainerService.maintenance()` to create containers for channels missing them
- Implement `ChannelMessageService.maintenance()` to re-render and upsert outdated messages
- Register `StatisticsService` in service registry for future metrics collection
- Refactor `shell` command to use dedicated `Shell` class instead of inline IPython setup
- Make `ContainerService.create()` idempotent by returning existing instance if present
- Fix `ComposeFileManager.get_instance()` to return `None` for missing services instead of crashing
2025-06-09 04:46:43 +00:00
retoor c207634bd0 fix: correct font-awesome stylesheet path from fa640.all.min.css to fa640.min.css 2025-06-08 18:39:36 +00:00
retoor 8a32fa92f7 chore: remove debug print statements and commented console logs across push, docker, and service-worker files 2025-06-08 18:36:45 +00:00
retoor 4a135ba574 fix: correct running instance detection to check for None returncode instead of 0
The condition for determining if a Docker Compose instance is running previously checked
`returncode == 0`, which incorrectly treated successfully exited processes as still running.
Changed to `returncode == None` to properly detect processes that are still active, as a
None returncode indicates the process has not yet terminated.
2025-06-08 16:54:22 +00:00
retoor 53e0b4d16f fix: skip stop call for already running instances with zero returncode 2025-06-08 16:54:02 +00:00
retoor cf3f735de2 feat: add pty-based terminal multiplexing and transparent background theme
Implement pseudo-terminal (pty) for docker exec sessions, replacing subprocess pipes with os.read/write on master fd. Add transparent background theme to terminal instance. Introduce keyboard shortcuts: double Escape clears chat input, Escape+t shuffles star field, Ctrl+, focuses next textarea. Fix container refresh to send clear screen via websocket instead of local terminal write.
2025-06-08 16:49:47 +00:00
retoor 20d9e64094 chore: remove version field from default compose dict in ComposeFileManager
The default compose dictionary no longer includes the deprecated "version" key,
keeping only the "services" entry to align with modern Docker Compose specs.
2025-06-08 10:58:50 +00:00
retoor dfef80514e fix: move xterm dependencies from app.html to web.html and fix stream read size in docker.py
- Relocated xterm CSS and JS imports from app.html to web.html to ensure terminal dependencies are only loaded on pages that actually use the terminal component
- Changed stream.readline() to stream.read(1024) in docker.py's ComposeFileManager to read fixed-size chunks instead of line-buffered reads for binary stream handling
- Added conditional rendering of channel UID and container initialization in app.html to prevent JavaScript errors when no channel context exists
- Removed debug print statements and redundant json.loads/json.dumps serialization in get_instance method to clean up logging and fix instance object handling
2025-06-08 09:59:16 +00:00
retoor b09706ff36 feat: add container terminal websocket view with xterm integration and stdin forwarding
Add ContainerView websocket endpoint at /container/sock/{channel_uid}.json that streams container stdout/stderr to client and forwards stdin input. Introduce ContainerService.write_stdin method and refactor event listener system from queue-based to handler-based with automatic cleanup on handler failure. Implement client-side Container class using xterm.js terminal with FitAddon, connecting via binary WebSocket to display real-time container output. Update ComposeFileManager to create stdout/stderr readers for running containers and serialize instance data with json.dumps for proper type handling. Include hidden terminal div in web template and wire /container command to instantiate and render terminal on first invocation.
2025-06-08 09:41:25 +00:00
retoor 03ef49523d chore: add snek-container-compose.yml to gitignore to exclude compose file 2025-06-08 01:59:50 +00:00
retoor f308ca2e06 feat: add async container start/stop with event listener system to ComposeFileManager and RPCView
Implement asynchronous start and stop methods in ComposeFileManager for individual containers via docker compose commands, along with a container_event_handler callback that dispatches events to registered asyncio queues. Add corresponding start_container and stop_container RPC endpoints in RPCView, each guarded by channel membership authorization. Introduce event listener registration in ContainerService to support future event-driven workflows.
2025-06-08 01:58:38 +00:00
retoor ee3de12d18 feat: add container dialog UI and async status checks via docker compose ps
Replace direct mapper CRUD methods on ContainerService with compose-based get and get_status that asynchronously query docker compose ps for running/stopped state. Introduce a new ContainerDialogComponent web component with disabled button styles, a properties table, and start/stop/restart controls. Wire the /container command in the chat input to open the dialog with live status. Add RPC endpoints get_container and get_container_status that verify channel membership before returning container metadata and status.
2025-06-07 18:30:16 +00:00
retoor b90ab35f85 feat: update index.html meta tags and title to target AI professionals
Refine the page title and meta description/keywords/OG tags to shift the audience focus from "enthusiasts" to "professionals", and rephrase the description to emphasize secure collaboration, rapid onboarding, and audit-free operation.
2025-06-07 06:55:09 +00:00
retoor 0a43b01e36 feat: add about page with footer and navigation links across index and about templates
Add a new about.html page with a sticky footer using flexbox layout, updated meta description, and refined hero copy. Include .about-link styled anchors on index.html in the header, features section, self-host section, and footer to improve site navigation and user awareness of the about page.
2025-06-07 06:52:08 +00:00
retoor 6ebe87e29a feat: replace footer with topnav navigation bar in about.html
Add a full-width top navigation bar with home link, hover/active states, and responsive mobile layout. Remove the previous centered footer element and its associated CSS rules. The new nav uses a dark background, flexbox alignment, and includes an icon alongside the home link.
2025-06-07 06:31:23 +00:00
retoor 88fcae963a feat: replace Jinja2 templates with static HTML and add SEO meta tags for about and index pages
Replace the Jinja2 template inheritance in about.html with a complete standalone HTML5 document featuring inline CSS, gradient hero section, and structured content sections. Enhance index.html with comprehensive Open Graph, Twitter Card, and canonical meta tags including targeted keywords for developers, testers, and AI enthusiasts.
2025-06-07 06:27:10 +00:00
retoor 0ee38c98ee feat: add sandbox stylesheet and include sandbox template in base layout
- Inserted `<link rel="stylesheet" href="/sandbox.css" />` into the `<head>` of `base.html` to load sandbox-specific styling
- Added `{% include "sandbox.html" %}` inside the `<main>` block of `base.html` to render the sandbox component on every page
- Replaced the entire inline `<style>` block and full-page HTML structure in `index.html` with a minimal template, removing duplicated layout and CSS definitions
2025-06-07 05:56:57 +00:00
retoor 1a4f2bad8d chore: reformat imports and apply consistent code style across multiple modules
Reorganized import statements in __main__.py, app.py, balancer.py, mapper/__init__.py, model/__init__.py, and several model files to follow PEP8 conventions with grouped standard library, third-party, and local imports. Applied consistent formatting with black-style line wrapping, added missing trailing commas, and fixed whitespace issues in function signatures and class definitions.
2025-06-07 03:47:54 +00:00
retoor 8a246b751f feat: add container orchestration with ComposeFileManager and channel home folder creation
Introduce a new ComposeFileManager class for managing Docker Compose YAML files, enabling instance creation, removal, duplication, and listing. Refactor ContainerService.create to accept channel_uid and delegate to ComposeFileManager, mounting the channel's home folder as a volume. Add get_home_folder method to ChannelService that ensures the home directory exists under ./drive/{channel_uid}/container/home. Update channel creation flow to automatically call container.create with the new channel UID. Enhance the web template with a welcome message for empty channels, displaying basic command hints.
2025-06-07 03:46:30 +00:00
retoor b516d99121 feat: trigger notification creation after channel message persistence in ChatService
- Add async call to `self.services.notification.create_channel_message(message_uid)` after storing each channel message
- Ensure notification service is invoked with the message UID for downstream alerting
2025-06-06 14:10:48 +00:00
retoor 762d15394c fix: skip notification dispatch for non-final channel messages in NotificationService
The `send` method in `NotificationService` now checks the `is_final` flag on the
`channel_message` record before proceeding with notification dispatch. If the
message is not marked as final, the method returns early without sending
notifications to channel members. This prevents premature or duplicate
notifications for messages that may still be updated or processed further.
2025-06-06 14:03:16 +00:00
retoor f0d2222d10 fix: use channel_name instead of channel_member dict in push notification title 2025-06-06 13:57:36 +00:00
retoor 5babc957f5 fix: await channel_member.get_name() call in notification service to fix missing async
The notification service's `_notify_channel_members` method was calling `channel_member.get_name()` without the `await` keyword, causing a coroutine object to be passed instead of the resolved string value. This resulted in push notifications containing the string representation of the coroutine object rather than the actual channel member name. The fix adds the missing `await` to properly resolve the async method call.
2025-06-06 13:08:34 +00:00
retoor 450e89339c chore: remove external font-awesome cdn and add dual-size snek favicons
Remove the CDN link for font-awesome 6.4.0 all.min.css and replace the single
32x32 favicon with two separate favicon links for 32x32 and 64x64 sizes using
the new snek_logo naming convention.
2025-06-06 13:02:35 +00:00
retoor dd5eb1d2e5 chore: update manifest.json with expanded icon set and corrected metadata
- Change description from "Danger noodle" to "Snek Software Development Community"
- Add short_name, start_url fields; remove launch_path, screenshots
- Replace icon entries with sizes 32, 64, 128, 192, 256, 512, 1024
- Reorder related_applications and prefer_related_applications to end of file
2025-06-06 12:58:35 +00:00
retoor bc873dbed6 feat: add 8 new snek logo sizes and update manifest icons array
Add snek_logo images at 16x16, 32x32, 64x64, 128x128, 256x256, 640x480, 800x600, and 1024x1024 resolutions to the static image directory. Update manifest.json to include 144x144, 256x256, and 1024x1024 icon entries alongside existing 192x192 and 512x512 references.
2025-06-06 12:54:42 +00:00
retoor 80cf425138 feat: add strip_markdown helper and new logo sizes, update push notification to strip markdown
- Add strip_markdown() function to snek/system/markdown.py that removes code blocks, headings, images, links, emphasis, blockquotes, and special characters from markdown text
- Import and use strip_markdown in NotificationService to strip markdown from push notification messages
- Add new logo image files at 48x48, 72x72, 96x96, 144x144, 192x192, 384x384, and 512x512 sizes
- Update manifest.json icon references to use new snek_logo_192x192.png and snek_logo_512x512.png paths
- Fix channel_member reference in notification title from label to object representation
2025-06-06 12:46:59 +00:00
retoor 772d2ec2f4 fix: remove redundant encoding check from push notification body validation 2025-06-06 10:09:37 +00:00
BordedDev 3b0af36295 fix: remove redundant encoding field check from push notification body validation 2025-06-06 10:06:43 +00:00
retoor 9f5dfee463 feat: add Font Awesome 6.4.0 icon library stylesheet to app template
Include the minified Font Awesome 6.4.0 CSS bundle (fa640.all.min.css) as a new static asset and link it in the app.html template before the base stylesheet, enabling icon support across the application.
2025-06-06 09:35:56 +00:00
retoor 251d05afc7 feat: add push notification service with webpush encryption and registration model 2025-06-06 09:25:09 +00:00
BordedDev c424385c1a feat: add CSP middleware, HTML sanitization, spoiler CSS, and chat input rate-limit protection 2025-06-06 09:21:46 +00:00
retoor ab690d7c4f fix: ensure message finalization waits for pending network operations before updating UI state 2025-06-06 09:20:17 +00:00
retoor 3dc06e66f8 fix: replace hardcoded loop bound with dynamic times variable in sendMessage call 2025-06-06 01:46:19 +00:00
retoor 5457c23b4e feat: add ntsh method to send repeated messages to hardcoded channel in App class 2025-06-06 01:45:07 +00:00
retoor 2c35b31e3b fix: fix unreachable csp header injection after early return in csp_middleware
The csp_middleware function had a `return response` statement before the response object was assigned, causing the Content-Security-Policy header injection code to be unreachable. Moved the return statement after the header assignment to ensure CSP headers are properly applied to all responses.
2025-06-06 01:41:39 +00:00
retoor 243acb30c9 fix: add early return before handler call in csp_middleware to prevent response processing
The middleware function csp_middleware in src/snek/system/middleware.py was modified to include a premature `return response` statement before the actual handler invocation and CSP header injection logic. This change effectively bypasses the Content-Security-Policy header generation and nonce creation, causing the middleware to return an unprocessed response object without security headers.
2025-06-06 01:41:06 +00:00
retoor 70081614c4 fix: extend CSP policy to allow Cloudflare and molodetz.nl origins for scripts, styles, images, and connections 2025-06-06 01:39:33 +00:00
retoor 7994df23a9 feat: add CSP nonce generation and default policy string to middleware module
Introduce a constant `csp_policy` defining a Content Security Policy template with a `{nonce}` placeholder for script-src, and retain the existing `generate_nonce` helper for producing cryptographically random nonces. This prepares the middleware to inject per-request nonces into HTTP responses for improved XSS protection.
2025-06-06 01:36:42 +00:00
retoor d3c6e292cf fix: correct csp_middleware signature to accept request instead of app
The middleware function signature was updated from `(app, handler)` to `(request, handler)` to match the expected aiohttp middleware interface, ensuring the Content-Security-Policy header is properly applied to responses.
2025-06-06 01:35:31 +00:00
retoor d3a237dd72 fix: flatten nested middleware closure in csp_middleware to remove redundant async wrapper 2025-06-06 01:34:47 +00:00
retoor cd3a81087c feat: add csp_middleware with nonce generation to enforce Content-Security-Policy headers on all responses
Implement a new CSP middleware that generates a cryptographically random nonce per request and attaches a Content-Security-Policy header to every response. The middleware is registered in the Application middleware stack after ip2location_middleware, and the csp_middleware import is added to the module imports in app.py. The nonce is produced via secrets.token_hex(16) in the new middleware module.
2025-06-06 01:33:45 +00:00
retoor bbdcff8463 feat: extend forbidden element removal to include form and input tags
Add 'form' and 'input' to the list of HTML elements that are stripped from
sanitized output alongside existing 'script' tag handling. Include a continue
statement after replacement to prevent redundant attribute processing on
removed elements.
2025-06-06 01:28:05 +00:00
retoor 5c3ecb55e3 feat: sanitize HTML attributes and strip script tags in channel message rendering
Add whitelist_attributes call to ChannelMessageService.save to filter allowed HTML attributes on rendered message content. In template.py, skip attribute processing for non-Tag elements and remove script tags entirely by replacing them with empty string before attribute whitelisting.
2025-06-06 01:22:39 +00:00
retoor 29c9c07333 feat: add whitelist_attributes sanitizer to strip unsafe HTML attributes from rendered templates 2025-06-06 01:04:37 +00:00
retoor 1f231406bc feat: add bleach dependency and sanitize_html filter for Jinja2 templates
Add `bleach` to project dependencies in pyproject.toml and implement a `sanitize_html` function in `src/snek/system/template.py` that uses bleach to strip unsafe HTML tags and attributes. Register the function as a `sanitize` Jinja2 filter in `src/snek/app.py` to allow template output sanitization.
2025-06-06 00:34:32 +00:00
retoor 10446cd54d fix: comment out unused _escape override in MarkdownRenderer 2025-06-06 00:22:03 +00:00
retoor 49c7cbf1ab fix: disable harmful protocol allowance in MarkdownRenderer class
The `_allow_harmful_protocols` flag in `src/snek/system/markdown.py` was toggled from `True` to `False`, preventing the markdown renderer from permitting potentially dangerous URL protocols during HTML rendering.
2025-06-06 00:10:28 +00:00
retoor a5b6328e63 feat: add CSS styles for spoiler message reveal on hover with fade-in animation 2025-06-05 23:32:07 +00:00
BordedDev d8d49cdf2b feat: add spoiler message styling with hover reveal and delayed pointer events
Add CSS rules for `.message-content .spoiler` to hide child content via opacity, pointer-events, and visibility, and reveal it on hover/focus/active with a 0.3s opacity transition. Include a `delay-pointer-events` keyframe animation to prevent premature interaction during the reveal. Set spoiler container to a fixed 2.5rem height with overflow hidden, expanding on interaction.
2025-06-05 23:22:40 +00:00
BordedDev 20967d3331 chore: merge branch 'feat/push-notifications' into main with chat-input refactors
- Reformatted empty arrow function bodies in autoCompletions to use block syntax
- Removed unused properties (previousValue, lastChange, changed) from ChatInputComponent
- Added expiryTimer property and removed redundant constructor assignments
- Simplified set value method by removing fallback to empty string
- Refactored resolveAutoComplete to use for-of loop and early return on duplicate matches
- Updated starsRender call formatting for consistency
2025-06-05 20:06:54 +00:00
BordedDev 7bf5460491 fix: prevent duplicate message sends by queuing pending finalize calls and debouncing key repeat events 2025-06-05 17:41:43 +00:00
retoor 61cf025c8b fix: refactor chat input component to fix auto-complete desync and remove stale state tracking
Remove unused `previousValue`, `lastChange`, and `changed` properties from ChatInputComponent constructor that were causing state desynchronization. Refactor `resolveAutoComplete` method to return null on multiple matches instead of silently picking the last one, and add `expiryTimer` property for future timeout management. Clean up formatting inconsistencies across the component.
2025-06-01 20:07:51 +00:00
BordedDev a86c79077a refactor: remove unused previousValue, lastChange, and changed fields from ChatInputComponent 2025-06-01 18:57:28 +00:00
BordedDev 62add04d1c refactor: simplify resolveAutoComplete logic by removing counter and early return on duplicate match 2025-06-01 18:56:24 +00:00
BordedDev 6d44c26692 refactor: replace manual for-loop with flatMap in getAuthors method of ChatInputComponent 2025-06-01 18:53:02 +00:00
BordedDev ed3b8b69a8 chore: reformat whitespace and fix trailing spaces in chat-input.js 2025-06-01 18:48:17 +00:00
BordedDev 6c3e26aca3 feat: skip push notification for message sender in channel member loop
Add a guard condition in the notification creation flow to compare
channel_member["user_uid"] against user["uid"] before calling
notify_user, preventing the sender from receiving a push notification
for their own message.
2025-06-01 11:03:47 +00:00
BordedDev d3fe68a78e feat: add IP2Location geolocation middleware and user model fields for country, city, and coordinates 2025-06-01 10:43:37 +00:00
retoor 9728e3d05a fix: strip only width and height params from image URL on zoom instead of all query params 2025-06-01 10:05:35 +00:00
retoor c942e3ddd2 fix: add missing return statement in ip2location_middleware before ip processing
The middleware was missing a return statement for the response, causing the handler to never complete and ip processing code to be unreachable. Added `return response` before the ip extraction and geolocation logic to ensure proper middleware chain execution.
2025-06-01 07:42:06 +00:00
retoor dcd444a9b7 feat: add async update method to BaseMapper and BaseService for model persistence
Add `update` method to `BaseMapper` that updates a model's `updated_at` timestamp and persists the record using the table's update operation keyed by `uid`. Expose this through `BaseService.update` to provide a consistent service-layer interface for model updates.
2025-06-01 01:38:12 +00:00
retoor 062e21686d feat: store client ip address in user model and middleware
Add `ip` field to `UserModel` schema and populate it from the request IP in `ip2location_middleware` to persist the user's IP address alongside location data.
2025-06-01 01:33:58 +00:00
retoor 3dfa7b5d90 feat: add IP2Location geolocation middleware and user location fields
Add IP2Location dependency and binary database for IP geolocation lookups.
Introduce ip2location_middleware that automatically updates authenticated
users' location data (country, city, region, coordinates) based on their
request IP address, skipping private IPs and unauthenticated sessions.
Extend UserModel with new optional fields: country_short, country_long,
city, latitude, longitude, and region to persist the geolocation data.
2025-06-01 01:24:14 +00:00
BordedDev 4eabb7a817 chore: remove 29-line comment block with license and author header from push.py 2025-05-31 22:56:39 +00:00
BordedDev 8664f7564e feat: add async semaphore and run_in_executor wrapper to BaseMapper for thread-safe db operations 2025-05-31 22:54:17 +00:00
BordedDev abde6cb59e feat: add async semaphore and run_in_executor to BaseMapper for non-blocking DB calls
Introduce an asyncio.Semaphore(1) and a run_in_executor method in BaseMapper, wrapping synchronous table operations (find_one, exists, count, upsert) to prevent blocking the event loop. Add a loop property returning the current event loop.
2025-05-31 22:53:17 +00:00
BordedDev c177dbacc4 fix: strip only width and height params from image zoom URL instead of clearing all search params 2025-05-31 22:52:02 +00:00
retoor 668887b117 fix: restore synchronous execution in BaseMapper.run_in_executor by removing asyncio executor wrapper 2025-05-31 22:39:53 +00:00
retoor 8bbce912a0 fix: add asyncio semaphore to BaseMapper and enable run_in_executor for thread safety
The commit introduces a per-instance asyncio.Semaphore(1) in BaseMapper.__init__ and wraps the run_in_executor method with an async with self.semaphore block, replacing the previous synchronous direct call with an actual thread pool execution via loop.run_in_executor. This ensures that concurrent calls to run_in_executor are serialized, preventing race conditions when the mapper interacts with blocking I/O or shared resources, while also restoring the intended asynchronous offloading behavior that was previously commented out.
2025-05-31 22:38:22 +00:00
retoor 6fc29a1db6 fix: remove async executor wrapping in BaseMapper.run_in_executor to call func directly 2025-05-31 22:33:47 +00:00
retoor a7d5cafeb7 feat: migrate synchronous database calls to async via run_in_executor in BaseMapper
Add asyncio import and loop property to BaseMapper, introduce run_in_executor helper method, and convert all blocking database operations (find_one, exists, count, upsert, find) to async wrappers that offload work to the default executor thread pool
2025-05-31 22:28:34 +00:00
retoor fcbf318a40 refactor: replace named inner function with lambda in run_in_executor method 2025-05-31 22:24:45 +00:00
retoor d3384c88a5 feat: make database operations async by wrapping sync calls in run_in_executor
Add asyncio import and run_in_executor helper to BaseMapper class, converting all synchronous database calls (find_one, exists, count, upsert) to async by executing them in the default thread pool executor via the event loop.
2025-05-31 21:30:41 +00:00
BordedDev e16062f7e9 refactor: replace synchronous requests with async aiohttp in push notification handler
Remove unused `memberships` and `user` variables, eliminate debug print statements for headers, and migrate the HTTP POST call from the blocking `requests` library to an `aiohttp.ClientSession` with async context managers. Adjust response attribute access from `.status_code` to `.status` to match aiohttp's API.
2025-05-31 21:29:45 +00:00
BordedDev de8a99e13f feat: add self-signed TLS certificate and key for local development server
Add a PEM-encoded X.509 certificate (cert.pem) and its corresponding RSA private key (key.pem) to enable HTTPS for debug and testing environments. The certificate is self-signed with a 1-year validity period, issued to "Internet Widgets Pty Ltd" in the Netherlands, and includes basic constraints for CA usage.
2025-05-31 21:09:56 +00:00
BordedDev f0b668d6ea feat: add push notification service with WebPush registration and SSL support
- Introduce PushMapper and PushRegistrationModel for storing user push subscription data (endpoint, key_auth, key_p256dh)
- Implement PushService replacing old Notifications class, with async notify_user method sending WebPush payloads via aiohttp
- Register PushService in service registry and PushMapper in mapper registry
- Update NotificationService to trigger push notifications on new channel messages
- Enable SSL context in main app runner loading cert.pem and key.pem
- Remove deprecated get_notifications calls and old notification module
- Refactor PushView to use new PushService for key retrieval and subscription registration
- Add await navigator.serviceWorker.ready in push.js to ensure service worker readiness before fetching public key
2025-05-31 17:08:55 +00:00
retoor d9a0103924 chore: replace lru_cache with time_cache for get_url_content to auto-expire after 1 hour
The decorator on `get_url_content` in `src/snek/system/template.py` was changed from `@lru_cache(maxsize=128)` to `@time_cache(timeout=60*60)`, and a new import `from app.cache import time_cache` was added. This ensures cached URL content expires after 3600 seconds instead of persisting indefinitely up to a fixed cache size.
2025-05-31 11:52:04 +00:00
retoor 2b6f8b9eaa feat: add URL embedding with metadata extraction and responsive CSS styling
Implement embed_url function that fetches remote pages, extracts Open Graph and Twitter Card metadata (title, description, image, video, site name), and renders embedded preview cards for links in chat messages. Add LRU-cached HTTP fetching via requests, parse head meta tags with BeautifulSoup, and append embed HTML after anchor elements. Include responsive CSS for embed containers with max-height 400px media, rounded top corners, and styled text elements for site name, page title, description, and link.
2025-05-30 17:53:22 +00:00
retoor eada404335 feat: add user state tracking fields to ChatInputComponent
Initialize users array, textarea reference, _value, lastUpdateEvent, previousValue, lastChange, and changed properties in class body before constructor, and remove redundant _value initialization from constructor.
2025-05-30 17:32:18 +00:00
BordedDev 3ed67d3dfe chore: remove unused asyncio and aiohttp imports from template.py
Clean up the import block in src/snek/system/template.py by removing
the unused `asyncio` and `aiohttp` modules, and reordering remaining
imports to follow standard grouping conventions (stdlib, third-party,
local).
2025-05-30 00:14:45 +00:00
BordedDev 8e3956af9f feat: add URL embedding with metadata extraction and responsive CSS styling 2025-05-30 00:13:36 +00:00
retoor b9799a1a33 fix: increase default live-type interval from 3 to 6 seconds in chat-input.js 2025-05-29 00:42:31 +00:00
retoor ce2e12c78c feat: add hidden auto-completions and stars_render RPC for admin commands
Introduce a `hiddenCompletions` property on ChatInputComponent to support privileged slash commands like `/starsRender`, merged with existing auto-completions via `allAutoCompletions`. Modify `resolveAutoComplete` to match only the first word of input, enabling multi-word command arguments. Update the Enter key handler to invoke the hidden completion handler directly instead of setting the textarea value. Add a `stars_render` RPC method that broadcasts a `stars_render` WebSocket event to all online users in a channel, and a corresponding client-side listener that renders the message as a rainbow word on the star field with a delayed shuffle. Increase the deployment notification delay from 10 to 15 seconds to accommodate the new render sequence.
2025-05-28 19:49:30 +00:00
retoor 5fbecdbabb feat: add 'r' key shortcut to focus last reply link when chat input is inactive 2025-05-28 11:40:12 +00:00
retoor 604f18205e feat: add 'i' key shortcut to focus chat input when not active
Add keyboard event handler that intercepts the 'i' key press and focuses the chat input field, but only when the input field is not already active. This provides a quick keyboard shortcut for users to start typing without clicking the input area.
2025-05-28 11:36:43 +00:00
retoor 4cc732dd33 fix: guard message sound playback with is_final flag to prevent duplicate audio on cross-typing events 2025-05-28 10:31:17 +00:00
retoor d39b3de497 fix: restructure channel-message handler to remove is_final guard and flatten notification logic 2025-05-28 10:29:01 +00:00
retoor 4e5c9b0bd8 feat: enable live typing by default and add debug mode for socket events
- Set `live-type="true"` on chat-input element in web.html template
- Move `finalizeMessage` call from Enter key handler to `updateMessage` method in chat-input.js
- Add `_debug` flag toggling in App class with debug method and socket debug logging
- Change socket event listener from "event" to "data" with conditional debug output
- Fix channel_uid reference in chat.py broadcast call
- Add scroll-to-bottom behavior after message text update in message-list.js
- Remove console.info statements from message-list.js scroll methods
- Add null check for message in finalize_message RPC handler and return boolean
2025-05-28 09:38:16 +00:00
retoor e8055422c4 fix: create parent directories recursively for host key path
The `.ssh` directory may not exist when the server starts, causing a `FileNotFoundError` because `mkdir` without `parents=True` fails if intermediate directories are missing. Added `parents=True` to ensure the full path `drive/.ssh/` is created recursively.
2025-05-28 08:40:05 +00:00
retoor bdc8bb43c5 feat: add is_final flag to channel messages and implement message finalization flow
Add `is_final` boolean field to ChannelMessageModel with default true, extend ChannelMessageService.create to accept and store the flag, implement ChatService.finalize method that marks a message as final and broadcasts its complete data, and update RPCView with finalize_message endpoint that validates ownership before finalizing. Modify chat-input.js to pass `is_final` parameter based on live typing state and trigger finalization on Enter key press.
2025-05-27 21:14:06 +00:00
retoor a0b0b19034 fix: assign sorted result back to variable in get_recent_users
The original code called sorted() without assignment, leaving the results list unsorted. This fix assigns the sorted list back to the results variable, ensuring the returned list is correctly ordered by user nick.
2025-05-27 20:22:32 +00:00
retoor c905ffea6a feat: filter online users to only those active in last 3 minutes and sort by nick
The `get_recent_users` query in `channel.py` now includes a `WHERE` clause filtering `user.last_ping` to within the last 3 minutes, replacing the previous unfiltered `get_online_users` call. The `rpc.py` handler removes manual deletion of sensitive fields (`email`, `password`, `deleted_at`, `updated_at`) and adds sorting of results by the `nick` key.
2025-05-27 20:16:41 +00:00
retoor e8b6060392 feat: remove hardcoded black backgrounds from CSS and add z-index to star animation
Remove all explicit `background-color: #000000` declarations from base.css across header, sidebar, chat-header, chat-input, input fields, footer, and main content areas. Add `z-index: -1` to the star pseudo-element in sandbox.css to ensure stars render behind other page content without interfering with layout.
2025-05-27 12:28:07 +00:00
retoor 08e7c1cd59 feat: add get_recent_users endpoint and update mention regex to include hyphens
Implement a new `get_recent_users` RPC method that returns the 30 most recently active channel members ordered by `last_ping`, backed by a new `ChannelService.get_recent_users` query joining `channel_member` with `user` table. Update the chat input component to call `getRecentUsers` instead of `getUsers` on initialization, and extend the mention extraction regex to support hyphens in usernames (`@([a-zA-Z0-9_-]+)`). Remove a debug `console.warn` call from the mention distance calculation logic.
2025-05-27 12:03:01 +00:00
retoor 09978ac2d3 fix: rename isSubsequence parameters from input/array to s/t for clarity 2025-05-27 10:33:04 +00:00
retoor 1d25e03f1a fix: correct isSubsequence logic to use single string comparison instead of array iteration 2025-05-27 10:30:42 +00:00
retoor e412ea2655 fix: change console.info to console.warn and remove debug logs in mention matching 2025-05-27 10:21:40 +00:00
retoor aea5d08695 fix: adjust mention matching distance penalty and move debug log outside loop 2025-05-27 10:16:47 +00:00
retoor 6a62eef9f6 fix: increase penalty for non-subsequence mention matches in chat input
The levenshtein distance penalty for mentions that are not a subsequence of the author name was increased from 1 to 10, making non-subsequence matches significantly less likely to be selected as the closest author. Additionally, the console.info logging was moved outside the minDistance update block to log the distance for every mention comparison, not just when a new closest author is found.
2025-05-27 10:14:02 +00:00
retoor dd4e79cc88 fix: add console.info logging for mention matching debug in chat input 2025-05-27 10:12:03 +00:00
retoor b3074b26fd fix: adjust levenshtein distance variable declaration from const to let in autocomplete
The change modifies the `distance` variable declaration inside the `ChatInputComponent` autocomplete logic from `const` to `let`, enabling reassignment when the mention is not a subsequence of the author name. This ensures the distance penalty is correctly applied during user mention filtering.
2025-05-27 10:10:26 +00:00
retoor 763688be5f fix: correct method name from isSubSequence to isSubsequence in mention matching 2025-05-27 10:08:53 +00:00
retoor d5a4d44480 fix: add subsequence check to improve mention matching accuracy in chat input
The levenshtein distance calculation for author mentions now includes a subsequence check to penalize matches where the mention text is not a subsequence of the author name. This prevents false positive matches where characters are present but in incorrect order, improving the accuracy of author mention suggestions in the chat input component.
2025-05-27 10:06:35 +00:00
retoor ee1af97e26 fix: replace mention placeholders with author names before sending chat message 2025-05-27 09:45:23 +00:00
retoor 113b189d88 refactor: remove unused lastMessage variable and query from web template
The diff removes the `lastMessage` variable declaration and its associated DOM query (`messagesContainer.querySelector(".message:last-child")`) from two locations in the web.html template: the channel-message event handler and the updateLayout function. This variable was previously assigned but never referenced elsewhere in the code, making it dead code that can be safely eliminated to reduce clutter and improve maintainability.
2025-05-27 09:03:25 +00:00
retoor d545be93d5 feat: switch user loading to async promise in chat-input component
The user fetch in chat-input.js now uses a non-blocking `.then()` callback
instead of `await`, allowing the component to render without waiting for
the RPC call to resolve.
2025-05-27 08:57:18 +00:00
retoor cc5b28ed95 fix: fix variable name from content to context in channel message save method 2025-05-27 08:50:09 +00:00
retoor 9c5a92efe2 fix: replace global isElementVisible with MessageList method for scroll detection
Move the isElementVisible utility into the MessageList class as a method and add a new isScrolledToBottom method that checks visibility of the .message-list-bottom element. Update the channel-message event handler to call messagesContainer.isScrolledToBottom() instead of the removed global function, ensuring scroll-to-bottom behavior is correctly determined after new messages arrive.
2025-05-27 08:43:44 +00:00
retoor ebe16175db feat: auto-add non-private channel membership on missing member in WebView
When a channel member is not found for the current user, automatically create a new membership record with default permissions if the channel is not private, instead of immediately returning a 404 error. This ensures users are properly joined to public channels they access.
2025-05-27 08:32:09 +00:00
retoor 340ead63b9 refactor: extract image click-to-zoom logic into dedicated function in web.html
The inline click handler for image zoom overlay creation and removal was moved from the event listener registration block into a standalone function, improving code modularity and reusability.
2025-05-27 08:31:36 +00:00
retoor fe9ae0380e refactor: add avatar-img class to img element in message template for consistent styling 2025-05-27 08:31:30 +00:00
retoor 7ef23cffaa feat: add image click-to-expand overlay in MessageList connectedCallback
Implement a fullscreen overlay for image clicks in the chat message list, excluding avatar images. The overlay displays the clicked image without query parameters, centered on a dark background, and removes itself on click.
2025-05-27 08:30:58 +00:00
retoor e45bed1000 feat: enrich message render context with user info fields
Extend the `save` method in `ChannelMessageService` to fetch the associated user record via `user_uid` and merge user fields (`uid`, `username`, `nick`, `color`) into the template rendering context, replacing the previous direct use of `model.record`.
2025-05-27 08:30:31 +00:00
retoor 9da3740c4e feat: add AvatarView web endpoint for generating animal avatars with multiavatar library
- Create new `avatar_animal_view.py` module with `AvatarView` class inheriting from `BaseView`
- Implement `get` method that accepts a `uid` parameter from request match info
- Integrate `multiavatar` library for avatar generation alongside existing `generate_avatar_with_options` helper
- Set `login_required = False` to allow public access to avatar generation endpoint
- Initialize `self.avatars` dictionary in constructor for potential caching of generated avatars
2025-05-27 08:29:44 +00:00
retoor bf75075bc1 fix: restructure layout to CSS grid and update viewport meta for mobile responsiveness
- Changed body layout from flexbox to CSS grid with explicit grid-template-areas for header, sidebar, and chat-area
- Added `height: 100%` to html element and changed body height from `100vh` to `100%` to prevent mobile browser chrome issues
- Updated viewport meta tag with `interactive-widget=resizes-content` to handle virtual keyboard resizing on mobile
- Moved main element after sidebar in DOM order and assigned grid-area to main and header
- Added `.chat-messages` flex container and fixed indentation inconsistencies across CSS selectors
- Changed umami analytics script from `defer` to `async defer` for non-blocking loading
2025-05-26 22:27:33 +00:00
retoor 6863276a64 fix: inline replaceMentionsWithAuthors call in updateMessageText to prevent missing letter bug 2025-05-26 22:25:56 +00:00
BordedDev 5d22454230 feat: restructure layout to CSS grid with named areas and add interactive-widget viewport meta
Replace flexbox body layout with a CSS grid using named template areas (header, sidebar, chat-area) to improve mobile responsiveness and sidebar positioning. Update viewport meta tag to include `interactive-widget=resizes-content` for better mobile keyboard handling. Add `html { height: 100% }` and `.chat-messages { display: flex; flex-direction: column }` to support proper vertical sizing. Move `<main>` element after sidebar block in app.html template to match new grid structure. Change umami analytics script from `defer` to `async defer` in base.html.
2025-05-26 22:23:41 +00:00
retoor f135c94d2a feat: constrain main content area to viewport height with overflow hidden
The change adds inline style `max-height: 100vh; overflow-y: none` to the `<main>` element in `src/snek/templates/app.html`, preventing the main content area from exceeding the viewport height and disabling vertical scrolling within it. This ensures the layout stays within the visible screen bounds, likely to support a fixed sidebar or header layout without page-level scroll.
2025-05-25 18:50:32 +00:00
retoor fb30c819b4 feat: restore avatar caching with redis and unique uid fallback in get_animal
- Add redis caching for generated avatars using key prefix "avatar_animal_"
- Handle "unique" uid by generating a random uuid4 to bypass cache
- Remove in-memory avatars dict and _get method's caching logic
- Return cached avatar immediately if found in redis before generating new one
2025-05-25 17:01:28 +00:00
retoor 368d0d3778 fix: restore avatar retrieval by switching type param from path to query
The avatar endpoint was broken after a previous refactor changed the type parameter source from query string to URL path match_info. This commit reverts that change in the `get` method of `AvatarView` class, restoring the original behavior where `type` is read from `self.request.query.get("type")` instead of `self.request.match_info.get("type")`. This fixes the 404 errors and missing avatar images for animal and default avatar types.
2025-05-25 16:55:39 +00:00
retoor af353b0feb fix: restore avatar endpoint by adding type-based routing with animal and default handlers 2025-05-25 16:51:55 +00:00
retoor 54b193e84f feat: add ChannelView route and reorder channel member data fetching
Add new GET route "/c/{channel:.*}" mapped to ChannelView for channel support. Move parent_object retrieval before other_user and last_message lookups in channel member subscription loop to ensure data dependencies are resolved in correct order.
2025-05-25 10:30:43 +00:00
retoor b02527224f feat: add ChannelView with auto-creation and redirect for missing channels
Implement ChannelView handler that resolves channel by name with multiple lookup attempts, creates a new channel if none found using query parameters for visibility flags, assigns creator as moderator, and redirects to the channel's HTML page.
2025-05-25 10:30:26 +00:00
BordedDev 0df2d595e5 fix: inline replaceMentionsWithAuthors call in updateMessageText handler 2025-05-25 00:46:49 +00:00
retoor 3c75e77a43 fix: prevent duplicate message sending on live typing submit
The fix addresses a race condition where pressing Enter to send a message while live typing was active would trigger both the submit handler and the live typing update, resulting in the same message being sent twice. The solution reorders the cleanup sequence in the submit handler to clear the input value before calling updateMessage, and conditionally assigns the messageUid only when live typing is enabled in the sendMessage callback.
2025-05-25 00:29:04 +00:00
BordedDev fd859ac552 fix: prevent duplicate message send on live typing submit by clearing input before send
The fix reorders operations in the submit handler to clear the input field value before calling `sendMessage`, and moves the `messageUid` assignment into the live-typing path only, eliminating the double-send that occurred when both the submit handler and the live-typing timer triggered message transmission.
2025-05-25 00:23:52 +00:00
retoor 66854636d3 feat: remove debug console.info calls for key and escPressed in keydown handler 2025-05-24 23:28:42 +00:00
retoor c62e1438eb feat: replace DOM-based author extraction with RPC user list in ChatInputComponent
Refactor getAuthors() to fetch users from app.rpc.getUsers() instead of scanning DOM elements. This improves performance and reliability by using the server-side user list, which includes both username and nick fields. Also add async initialization of this.users in connectedCallback() using the channelUid attribute.
2025-05-24 23:25:43 +00:00
retoor 6aa91eaf4e fix: replace last-child selector with :not(:has(+ .message)) for accurate last message time display 2025-05-24 16:46:43 +00:00
BordedDev f96a179861 fix: replace last-child selector with :not(:has(+ .message)) for accurate last message time display 2025-05-24 16:41:12 +00:00
retoor 72097bf7ae fix: enable shift+G shortcut to focus chat input when input is inactive 2025-05-24 13:00:26 +00:00
retoor c374cbddc6 feat: replace escape-g scroll-to-bottom with scroll-to-top and load-extra on double-g
The key handler for the double-press of 'g' now scrolls to the first message and triggers `loadExtra()` instead of scrolling to the last message and focusing the input. The escape key press no longer sets a flag but instead blurs the active element. The shift-G handler now always prevents default and updates layout regardless of input focus, with the focus condition commented out.
2025-05-24 12:45:53 +00:00
retoor 46e9732c0f fix: wrap reply message content in markdown code block in web template
The replyMessage function now encloses the quoted message in triple backticks with a markdown language specifier, ensuring proper rendering of the quoted content in the chat input field.
2025-05-24 12:22:12 +00:00
retoor 69f085e39f feat: refactor web template to cache DOM selectors and throttle refresh handler
Extract repeated DOM queries into module-level constants for chatInputField, messagesContainer, chatArea, channelUid, and username. Replace inline arrow functions in autoCompletions with direct references and a single-line clear implementation. Add a generic throttle utility and apply it to the refresh event listener to prevent rapid reloads on repeated server notifications.
2025-05-24 12:18:38 +00:00
retoor 1995d12ff8 feat: insert new chat messages before message-list-bottom element instead of appending 2025-05-24 10:58:43 +00:00
retoor e53bce9e28 feat: add author mention matching with levenshtein distance in chat-input 2025-05-23 17:50:58 +00:00
retoor 529b3f2173 fix: ensure datetime and reply visibility for long date gaps and force last message time opacity to 1
Add `.message:has(+ .message.long-time)` selector to show time display when a message is followed by one with a date gap exceeding 20 minutes. Set `.time` opacity to 1 for both this case and the last message to override any inherited lower opacity. In the JavaScript, track `previousDate` alongside `previousUser` and add/remove `long-time` class based on a 20-minute threshold between consecutive messages' `created_at` timestamps.
2025-05-23 23:48:07 +00:00
BordedDev 02d877ac89 fix: correct variable reference in start time stripping for youtube embed
The bug was in `embed_youtube` function in `src/snek/system/template.py` where the variable `start_time` was being sliced instead of the loop variable `t` when stripping trailing 's' characters from time values. This caused incorrect start time parsing for all entries after the first in multi-start queries.
2025-05-23 16:14:32 +00:00
retoor b98f7741ad fix: correct string slicing bug in youtube timestamp parsing for start time
The bug caused incorrect extraction of the numeric value from timestamp strings ending with 's' (e.g., "30s"). Previously, the code sliced `start_time[:-1]` (the list) instead of `t[:-1]` (the individual string element), resulting in the entire list being sliced rather than removing the trailing 's' character from each timestamp. This fix ensures proper parsing of YouTube embed start times by applying the slice operation to the correct variable.
2025-05-23 16:13:50 +00:00
BordedDev a83452b3f6 feat: add application uptime tracking and new dumb-terminal web component
Add `time_start` initialization and `uptime_seconds`/`uptime` properties to the Application class, including a `_format_uptime` helper that converts seconds into a human-readable string with days, hours, minutes, and seconds. Introduce a new `DumbTerminal` custom element in `dumb-term.js` with a retro terminal UI, command history navigation, and a shadow DOM-based layout. Additionally, reformat multiple JavaScript files (app.js, chat-input.js, chat-window.js, event-handler.js, fancy-button.js) to use consistent 2-space indentation and double quotes, and remove trailing whitespace from license comments.
2025-05-23 13:45:56 +00:00
BordedDev ba3235402b feat: add uptime property and DumbTerminal component to app and static assets
Add `uptime_seconds` and `uptime` properties to the Application class in `src/snek/app.py`, tracking server runtime from `datetime.now()` on startup. Introduce a new `DumbTerminal` custom element in `src/snek/static/dumb-term.js` providing a retro terminal UI with command history. Reformat multiple JS files (app.js, chat-input.js, chat-window.js, event-handler.js, fancy-button.js) from 4-space to 2-space indentation and unify quote style to double quotes.
2025-05-23 13:34:30 +00:00
retoor 95b675ac36 fix: re-enable forced scroll-to-bottom and add immediate scrollHeight set in MessageList 2025-05-23 13:26:47 +00:00
retoor 34e13e4142 fix: fix typo in scheduled list removal and adjust refresh timing in RPCView.get 2025-05-23 05:07:19 +00:00
retoor 2a831a8a72 fix: correct typo in starField property name and add null initialization in App class 2025-05-23 05:02:45 +00:00
retoor ea7021d4a0 feat: add color parameter to triggerGlow and track scheduled tasks in RPCView
- Extend triggerGlow method in MessageList to accept optional color parameter and call app.startField.glowColor(color)
- Add _scheduled list to RPCView session class to track pending scheduled calls
- Refactor _schedule method to use self.user_uid instead of passed uid parameter and manage _scheduled list
- Add scheduled list tracking in RPCView.get() and conditionally skip deployment refresh if scheduled tasks exist
2025-05-23 05:00:28 +00:00
retoor 2ea459eb8e feat: add uptime tracking, starfield day/night theme, and RPC session management
- Add `time_start` and `uptime` property to Application class for server uptime tracking
- Implement starfield CSS with day/night theme toggle and special star effects
- Add `reconnecting` event emission in Socket class and corresponding UI notification
- Introduce RPC session management with `_session_ensure`, `session_get`, and `session_set` methods
- Add `_schedule` helper in RPCView for delayed message delivery to users
- Wire up `refresh`, `deployed`, and `starfield.render_word` WebSocket events in web template
2025-05-23 04:48:18 +00:00
retoor ad05e19c19 fix: switch user fetch to async pattern and add delayed focus in chat input
- Changed `connectedCallback` to set `this.user = null` immediately and fetch user asynchronously via `.then()` instead of blocking with `await`
- Added `setTimeout` with 1000ms delay to call `this.focus()` after message send handler registration
- Updated `scrollToBottom` in message-list to use `scrollIntoView` on `.message-list-bottom` element with a 200ms retry timeout instead of direct `scrollTop` assignment
2025-05-23 01:01:20 +00:00
retoor eea2a03f36 fix: restore direct scrollTop assignment in MessageList scrollToBottom
The scrollToBottom method previously used scrollIntoView on the
message-list-bottom element, which caused inconsistent scrolling behavior
when new messages were added. Reverting to the direct scrollTop assignment
ensures the container scrolls reliably to the full content height.
2025-05-23 00:39:48 +00:00
retoor 8242d0db10 fix: pass color parameter to triggerGlow and fix scrollToBottom method in MessageList 2025-05-22 23:17:34 +00:00
retoor b835ccc2f6 chore: normalize indentation and quote style across static JS modules
Standardize all JavaScript files in src/snek/static/ from 4-space to 2-space indentation and convert single quotes to double quotes for string literals, ensuring consistent formatting across app.js, chat-input.js, chat-window.js, event-handler.js, fancy-button.js, and file-manager.js. Also add new DumbTerminal custom element class in dumb-term.js.
2025-05-22 22:44:18 +00:00
BordedDev bbaa458a62 fix: correct string slicing to strip trailing 's' from each start time element instead of the entire list 2025-05-22 06:10:39 +00:00
BordedDev 530f9ab27c feat: show reply time indicator when message gap exceeds 20 minutes
Add logic to detect time gaps between consecutive messages from the same user. When the difference between two messages' timestamps exceeds 20 minutes, the later message receives a `long-time` CSS class, causing its timestamp to be displayed persistently alongside the existing `switch-user` rule. The CSS selector `.message:has(+ .message.long-time)` ensures the preceding message's time remains visible when followed by a long-delayed reply.
2025-05-21 22:19:39 +00:00
BordedDev bf32ab8d47 fix: set opacity to 1 for last message's time display in chat CSS 2025-05-21 22:00:51 +00:00
retoor 94ce590957 feat: hide avatar via max-height and fade time on non-hover messages
- Replace avatar opacity-only hiding with max-height:0 and overflow:hidden for collapsed state
- Remove .time from the display:none rule shared with .author
- Add CSS rule to fade .time to opacity:0 when message row is not hovered, focused, or active
- Restore max-height:unset on .avatar when message is hovered to ensure smooth reveal
2025-05-21 21:36:25 +00:00
BordedDev 3279f3f861 feat: hide avatar via max-height and fade time on non-hover messages
Add max-height:0 with overflow:hidden to .avatar in collapsed state, and set
max-height:unset on hover to prevent layout shift. Replace .time display:none
with conditional opacity:0 when message is not hovered, focused, or active,
keeping .author display:none unchanged.
2025-05-21 20:30:15 +00:00
retoor 37a011d2f9 fix: replace fragile YouTube URL parsing with urlparse and add missing file check in attachment view
- Refactor embed_youtube to use urlparse and parse_qs for robust YouTube link detection
  across multiple hostnames (youtu.be, youtube.com, youtube-nocookie.com) and path variants
- Add missing file existence check in ChannelAttachmentView to return 404 when attachment
  file is not found on disk
- Improve image saving in attachment view with quality=100, optimize=True, save_all=True
- Set Content-Type header from original format in attachment download response
2025-05-20 20:02:16 +00:00
retoor a567881677 feat: add animal-themed avatar generator with caching and star position logic
Implement a new avatar generation system using the AnimalAvatarGenerator class that produces SVG animal avatars based on query parameters. Add an in-memory cache in AvatarView to store generated avatars per user ID, and introduce a getStarPosition helper in sandbox.html that classifies star positions into cardinal directions and center/edge regions.
2025-05-20 02:20:10 +00:00
BordedDev 7e29f6d51b feat: add container CRUD views, mapper, service, model and update chat-input typing color 2025-05-20 01:34:05 +00:00
retoor fcc7ba9587 fix: reduce glow intensity and expose star color update function
The glowCSSVariable lighten factor was reduced from 80 to 10 to produce a more subtle visual effect. The updateStarColorDelayed function is now exposed on the app object as app.updateStarColor for external access. The unused StarField class and its instantiation have been commented out to clean up dead code.
2025-05-18 23:35:34 +00:00
retoor cfbb28a562 fix: increase glow color lightening from 40 to 80 in sandbox CSS variable function 2025-05-18 23:28:22 +00:00
retoor 4a2ac81809 feat: add StarField class with configurable twinkling stars to sandbox page
Introduce a new StarField JavaScript class that generates animated star elements with customizable count, size range, speed, and color. Instantiate it on document body with default settings to enhance the sandbox visual experience.
2025-05-18 23:23:12 +00:00
retoor 01dbace535 fix: increase glow color lightening from 20 to 40 in sandbox CSS variable animation 2025-05-18 23:13:18 +00:00
retoor 4fcdcc1745 feat: add container settings views and enhance typing indicator with user color
Add four new container management views (index, create, update, delete) with corresponding router entries in the application. Refactor sandbox star field to use ES modules and CSS custom properties for dynamic star color. Extend the typing indicator RPC method to accept an optional color parameter, falling back to the user's stored color, and include it in the broadcast payload. Update the chat input component to fetch the current user asynchronously and pass their color when triggering typing events.
2025-05-18 23:07:11 +00:00
retoor d18bb8aa39 feat: add Container model, mapper, service, and CRUD templates for container management
Introduce a new Container entity with fields for id, name, status, resources, user_uid, path, and readonly. Register ContainerMapper in the mapper registry, Container model in the model registry, and ContainerService in the service registry. Implement create, get, update, and delete methods in the service layer. Add Jinja2 templates for listing, creating, updating, and deleting containers under settings/containers/, including form styling and Font Awesome icons.
2025-05-18 17:47:15 +00:00
retoor 88530346c5 feat: add starfield background animation and link sandbox.css to index.html
- Change default theme from dark2 to light1 in gitlog.py
- Update server port from 8000 to 8481
- Remove background-color styling from diff line highlights in gitlog.py
- Switch index.html body background from #111 to #000
- Add link to /static/sandbox.css stylesheet
- Inject JavaScript generating 200 animated star elements with randomized position, size, and flicker timing
2025-05-18 15:48:22 +00:00
BordedDev c9b9a1d7ea fix: preserve gif quality and animation frames in channel attachment resizing 2025-05-18 12:51:38 +00:00
BordedDev 50df05a62d feat: add animated starfield background to app via sandbox.css and sandbox.html template
Introduce a twinkling starfield effect on the app page by creating a new `sandbox.css` stylesheet with `.star` positioning and `twinkle` keyframes, and a `sandbox.html` template that generates 200 randomized star elements with varying size, animation duration, and delay. Wire both into `app.html` by linking the stylesheet and including the template before the closing body tag.
2025-05-18 01:26:53 +00:00
retoor 262f5e0461 feat: add twinkling star background with CSS animation and JS generation
Add a new sandbox.css stylesheet defining absolute-positioned star elements with a twinkle keyframe animation, and a sandbox.html template that generates 200 randomized stars (position, size, animation duration/delay) via JavaScript. Include both files in app.html via link and include directive.
2025-05-17 15:46:59 +00:00
BordedDev c0c9b942a0 fix: restore webp fallback in picture template and add file existence check in attachment view 2025-05-17 11:55:46 +00:00
BordedDev e450716508 fix: re-add webp format parameter to image src for fallback support
- Append `&format=webp` to the image source URL in `enrich_image_rendering` function
- Restore webp format query parameter that was previously removed, enabling browser fallback to webp when supported
2025-05-17 11:29:25 +00:00
BordedDev db776b4a6a fix: rewrite YouTube embed parsing with urlparse and handle missing channel attachments
- Replace fragile string splitting with urlparse/parse_qs for robust YouTube URL parsing
- Support youtu.be, youtube-nocookie.com, and /embed paths alongside /watch
- Properly extract start time (t=) parameter and append to embed src
- Add early 404 return in ChannelAttachmentView when channel_attachment is None
- Set Content-Type header from original format in attachment download response
2025-05-17 11:23:32 +00:00
retoor 7f0aea4afd feat: replace inline chat-input with custom element and migrate autocomplete logic
Refactor the chat input area in web.html to use the new ChatInputElement custom component instead of inline HTML. Move autocomplete handler from a function-based getInputField() to directly attaching to the chat-input element's autoCompletions property. Remove the legacy initInputField function and its associated keydown/input event listeners, consolidating all input behavior into the component. Add the chat-input.js module script to app.html to ensure the custom element is registered globally.
2025-05-16 22:54:15 +00:00
retoor 0a0f817a07 feat: replace bare-bones HTML with full landing page featuring hero, grid, and dark theme
Complete redesign of the index.html template from a minimal stub to a polished landing page. Adds a gradient hero section with call-to-action buttons, a responsive feature card grid, and a cohesive dark color scheme with hover effects. Introduces CSS reset, container layout, and link styling for a modern, production-ready appearance.
2025-05-16 22:53:27 +00:00
retoor 3589ad301a feat: add enabled flag to Cache class with early returns in get/set/delete
Add an `enabled` attribute initialized to `False` in the Cache constructor, and guard clauses at the start of `get`, `set`, and `delete` methods that return immediately when the cache is disabled, allowing runtime toggling of caching behavior without modifying callers.
2025-05-15 23:38:42 +00:00
retoor d803e87777 fix: increase last_ping threshold from 20 to 180 seconds in channel presence check
The threshold for considering a user as recently active in the channel presence
yield logic was raised from 20 seconds to 180 seconds, reducing the frequency
of presence updates and accommodating longer intervals between user pings.
2025-05-15 22:41:40 +00:00
retoor 6a3364d305 feat: batch file uploads into single message and fix attachment image format handling 2025-05-15 22:32:54 +00:00
retoor 50690d962e feat: add user availability service to periodically update online user timestamps
- Introduce `start_user_availability_service` method in Application class that creates an asyncio task for periodic user availability updates
- Implement `user_availability_service` coroutine in SocketService that iterates connected sockets every 60 seconds, updating each user's `last_ping` timestamp and persisting changes via the user service
- Add logging for socket addition and user availability service lifecycle events
- Reorder startup hooks to ensure user availability service starts before SSH server and database preparation
2025-05-15 22:04:19 +00:00
retoor 9c12bfead3 feat: add /live command entry to help dialog command list
Insert a new command object for "/live" with description "Toggle live typing mode" into the commands array in the help dialog template, placed after the existing "/img-gen" entry.
2025-05-15 21:30:23 +00:00
retoor 3a6da92c86 feat: replace inline online dialog with reusable dialog components and add help modal
Extract the online users dialog from `online.html` into a dedicated `dialog_online.html` template, create a new `dialog_help.html` template with a custom `help-command-list` web component, and add global `<dialog>` styling in `base.css` with centered positioning, backdrop blur, and fade/scale animations. Update `web.html` to include both dialog templates, wire the `/help` autocomplete command to `showHelp()`, and rename the `/online` handler from `showOnlineUsers()` to `showOnline()`.
2025-05-15 21:16:28 +00:00
retoor af7be99ab9 feat: add message edit time limit and broadcast full record on update
- Add `get_seconds_since_last_update` method to `ChannelMessageModel` for age checking
- Reject edits on messages older than 3 seconds in `updateMessageText` RPC handler
- Broadcast full message record (including html) instead of just text in socket event
- Update frontend `updateMessageText` to parse and set innerHTML from received html field
- Fix CSS selector for chat message images to use descendant combinator
- Add 3-second auto-clear timeout for liveType input field
- Prevent liveType submission on trailing newline or space
- Hide empty messages in channel-message event by setting display none
2025-05-15 17:32:40 +00:00
retoor 10b0c3448a feat: add MessageList and UserList web components with load balancer and sync module
Implement MessageList custom element with real-time message text updates and typing glow animation, add UserList component with relative time formatting, introduce LoadBalancer class for backend process management, create DatasetWebSocketView for key-value sync over WebSocket, override save method in ChannelMessageService to render HTML templates, and return channel_message object from ChatService instead of boolean.
2025-05-15 11:18:53 +00:00
retoor 6250a7a09c feat: url-encode attachment relative_url in upload response
Add urllib.parse import and encode the relative_url field of each attachment record before returning it in the JSON response for file uploads, ensuring special characters in URLs are properly escaped for client consumption.
2025-05-13 21:33:24 +00:00
retoor e15b70be41 fix: append height parameter to image src in enrich_image_rendering
Add `&height=240` query parameter alongside existing `?width=240` when
processing image source URLs in the BeautifulSoup-based template rendering
function, ensuring both dimensions are explicitly set for responsive image
generation.
2025-05-13 20:54:21 +00:00
retoor 976ff9b54b feat: add pointer cursor to chat message images for click interaction indication 2025-05-13 19:25:49 +00:00
retoor 8ff32f9293 fix: reduce image rendering width to 240px and strip query params on fullscreen 2025-05-13 18:35:42 +00:00
retoor f66c9132eb fix: remove redundant webp format query param from picture source srcset 2025-05-13 18:30:48 +00:00
retoor b035509471 fix: correct string concatenation for image src attribute in enrich_image_rendering
The previous implementation used `list.append()` on a string object, which is invalid since strings do not have an append method. Changed to use the `+=` operator to properly concatenate the "?width=420" query parameter to the image source URL within the BeautifulSoup HTML processing pipeline.
2025-05-13 18:28:31 +00:00
retoor e635865190 fix: append width query param to img src before building picture template 2025-05-13 18:27:26 +00:00
retoor efaef42287 fix: correct missing concatenation operator in image src attribute template 2025-05-13 18:25:00 +00:00
retoor f7d6e09481 fix: append width=420 query param to img src in enrich_image_rendering
The change modifies the img tag's src attribute within the picture template generated by the enrich_image_rendering function, adding a "?width=420" query parameter to the image URL while leaving the title and alt attributes unchanged. This ensures images rendered through this template are served at a constrained width of 420 pixels.
2025-05-13 18:24:29 +00:00
retoor 3f81cac93e fix: change attachment relative_url format and add overflow scroll to drive container 2025-05-13 18:21:00 +00:00
retoor 0d95dbdb9c feat: add gitlog.jsonl dataset and gitlog.py server with theme support 2025-05-18 14:55:02 +00:00
retoor 8881bc4439 fix: append width query param to image title in embed_image template
The change adds `?width=420` to the title attribute of embedded image tags, ensuring that when images are rendered, the title string includes a width hint for responsive or constrained display behavior. This affects the `embed_image` function in `src/snek/system/template.py` where image elements with extensions like `.jpg`, `.png`, `.gif`, `.webp`, `.heic` are processed.
2025-05-13 18:20:43 +00:00
retoor 549c98d55b feat: add click-to-expand image overlay with dark backdrop in web chat template
Implement a full-screen image preview overlay triggered by clicking any <img> element inside the messages container. The overlay uses a fixed dark background (rgba(0,0,0,0.9)) and centers the image at up to 90% viewport dimensions. Clicking the overlay dismisses it by removing the element from the DOM.
2025-05-13 18:18:47 +00:00
retoor 70b4b420d1 perf: remove per-task timing instrumentation from worker loop in Application
The diff removes the `time.time()` calls and the `print` statement that logged the duration of each task execution inside the worker loop of `Application`. This eliminates unnecessary overhead and console noise from the hot path.
2025-05-13 17:13:50 +00:00
retoor 070d8b2e6a refactor: extract WebSocketClient from serpentarium.py into dedicated system module with dynamic RPC method dispatch 2025-05-13 17:08:18 +00:00
retoor 6b7e53cba7 feat: add serpentarium dataset wrapper with websocket sync and sql schema
Introduce a new research module implementing a custom dataset wrapper with raw WebSocket client connectivity, a SQL schema for chat application tables (users, channels, messages, notifications), and a sync service that propagates database queries between nodes via WebSocket. Includes a performance test harness comparing the wrapper against direct dataset usage.
2025-05-13 16:30:31 +00:00
retoor 2c9efc3626 feat: add database init command, uvloop import, and snode sync service to snek app 2025-05-13 16:32:59 +00:00
retoor 1a1f3d5fac feat: add image conversion and resizing via query params in channel attachment view
Add Pillow and pillow-heif dependencies to pyproject.toml, implement image format conversion and dimension resizing in ChannelAttachmentView.get() using query parameters 'format', 'width', and 'height', and update markdown rendering to support strikethrough, spoiler, and URL plugins. Also fix uvloop import to gracefully fall back on Windows, clean up unused imports in sssh.py, and change upload markdown syntax from image to link format in web template.
2025-05-13 16:19:57 +00:00
BordedDev 414cce6c71 feat: add image conversion and resizing via query params in channel attachment view 2025-05-11 23:47:54 +00:00
retoor 5254593b0d fix: change default SSH port from 2042 to 2242 in Application class 2025-05-11 05:52:58 +00:00
retoor 75c8acc1ef feat: add SSH/SFTP server with sync auth and chroot per-user home folders
Introduce a new SSH server module (snek/sssh.py) that provides SFTP access using asyncssh. The server authenticates users synchronously via the existing password validation logic, chroots each session to the user's dedicated drive directory, and is started as an application startup hook on 0.0.0.0:2042. Refactor security helpers (hash, verify) to expose synchronous variants for use in the SSH authentication flow without async context.
2025-05-11 05:52:22 +00:00
retoor 8cfeed72ea fix: remove hardcoded repo path and user credentials, comment out list endpoint 2025-05-10 19:44:58 +00:00
retoor ad385cb1bd fix: remove debug print of user_uid in channel broadcast loop
Remove a leftover debug print statement that was logging each user_uid
to stdout during channel message broadcasting in SocketService.
2025-05-10 18:40:56 +00:00
retoor 8fc126d45a feat: add channel attachment upload and retrieval with dedicated model, mapper, service, and view 2025-05-10 18:38:32 +00:00
retoor bb56df9183 feat: add typing indicator with glow animation and event broadcasting
Implement real-time typing indicator feature across frontend and backend. Add `set_typing` RPC method in `RPCView` that broadcasts typing events with user details to a fixed channel. Introduce `typeLock`, `typeListener`, and `set_typing` method in `App` class to periodically send typing status. Update `Socket` to emit custom events from incoming data. Add CSS `glow` keyframe animation and apply it to user avatars in `web.html` when `set_typing` event is received.
2025-05-10 13:08:28 +00:00
retoor 82f1259cfc feat: add DriveApiView and HTML drive page, extract shared repository form template, and update file-manager styling 2025-05-10 13:03:50 +00:00
retoor aeb00226fe feat: add DBService with per-user sqlite database and RPC endpoints for crud operations
Introduce a new DBService that manages per-user SQLite databases stored under each user's home folder using the `dataset` library. The service provides async methods for insert, update, upsert, find, get, delete, and raw query operations. Corresponding RPC handler methods (db_insert, db_update, db_delete, db_get, db_find, db_upsert, db_query) are added to the RPCView class, each requiring an authenticated session via _require_login. The service is registered in the get_services factory and injected into the services dictionary.
2025-05-09 15:37:53 +00:00
retoor 6f638d7f8b feat: add humanize dependency and implement bare repository navigation with branch/commit browsing
Add `humanize` to project dependencies for human-readable file sizes and timestamps. Implement `BareRepoNavigator` class in repository view to support browsing bare git repositories by branch, commit, and path. Update route parameters from `repo_name`/`rel_path` to `repository`/`path` for consistency with other views.
2025-05-09 13:55:51 +00:00
retoor 6fc5706026 revert: restore unminified source formatting across snek package files
Revert commit 17c6124a57 which minified all Python source files, restoring original multi-line formatting, expanded variable names, and proper indentation in __main__.py, app.py, docs/app.py, dump.py, form/login.py, and form/register.py
2025-05-09 12:57:22 +00:00
retoor 1ebb54efc4 feat: add file browser custom element and repository view with git tree traversal
Introduce a <file-browser> web component with pagination, breadcrumb navigation, and grid-based file tiles. Implement RepositoryView that serves repository contents by walking the git commit tree, sorting entries by type and name, and rendering them via the repository.html template with folder/file icons and size display.
2025-05-09 12:30:53 +00:00
retoor 292a2651fd chore: minify Python source files by collapsing imports and inlining simple class bodies 2025-05-09 12:19:29 +00:00
retoor 7fbb06fdbf feat: add repository browsing routes, drive availability flag, and user lookup service
- Register RepositoryView and DriveView routes for repository and drive browsing
- Add `is_available` boolean field to DriveItemModel with default True
- Implement `get_by_username` method in UserService for username-based queries
- Comment out repository deletion logic in GitApplication
- Include file-manager.js script and Font Awesome CDN stylesheet in app template
- Fix repository URL paths in settings template to use correct attribute access
- Refactor DriveView with full directory listing and file serving capabilities
- Pass user object to repositories index template for template rendering
2025-05-09 12:08:46 +00:00
retoor b71731bfcf refactor: replace argparse CLI with click commands and add shell subcommand
Migrate the command-line interface from argparse to click, introducing a `cli` group with `serve` and `shell` subcommands. The `serve` command retains port, host, and db_path options with defaults, while the new `shell` command launches an IPython shell with the application context. Update Makefile targets to use the new `.venv/bin/snek` binary and add a `serve` alias for `run`.
2025-05-09 07:56:23 +00:00
retoor 5cb4233138 fix: increase client_max_size to 5GB in Application and GitApplication constructors
The Application class now passes a 5GB client_max_size limit to its parent constructor, while the GitApplication class has its existing limit raised from 100MB to 5GB, enabling support for larger file uploads and repository operations.
2025-05-09 07:18:55 +00:00
retoor 2aae42602e feat: implement basic auth credential parsing and user authentication in check_basic_auth
Replace the stub that always returned a fixed user and path with full HTTP Basic authentication logic. The method now extracts and base64-decodes the Authorization header, splits credentials, authenticates the user via the parent services layer, retrieves the user-specific repository path, and returns the username and path only on successful authentication.
2025-05-09 07:09:33 +00:00
retoor 370a7e43be feat: add uvloop dependency and fix repo name parsing in sgit.py
- Add uvloop to project dependencies in pyproject.toml for async event loop optimization
- Fix repository name extraction by replacing lstrip('git/') with slice [4:] to avoid stripping leading characters incorrectly
- Normalize string quotes from single to double for repo_dir path construction
- Add trailing whitespace to return statement in get_repository_path helper
2025-05-09 06:54:33 +00:00
retoor d8524d1f85 fix: fix typo in delete route and implement repository deletion with filesystem cleanup 2025-05-09 06:24:43 +00:00
retoor f65dc160c0 feat: add GitApplication subapp and repository init with gitpython
Add a new GitApplication subapp mounted at /git with smart HTTP protocol support for repository operations. Include gitpython dependency in pyproject.toml, fix recursive call in RepositoryService.exists by delegating to super(), and change RepositoryService.init to return boolean success instead of printing output. Also modify UserService.get_repository_path to always return a path object without checking existence.
2025-05-09 05:55:08 +00:00
retoor 06da973f3a feat: add repository management with CRUD views, mapper, service, and uvloop integration
Implement full repository management feature including RepositoryModel with user_uid, name, and is_private fields, RepositoryMapper for database persistence, and RepositoryService with exists, init, and create methods that spawn bare git repos via asyncio subprocess. Add four new settings views (index, create, update, delete) with corresponding templates and route registrations in Application. Register repository mapper, model, and service in their respective __init__ factories. Extend UserService with get_repository_path helper. Switch event loop policy to uvloop in __main__.py for improved async performance.
2025-05-09 03:38:29 +00:00
retoor 6cd8e774f4 fix: replace static route with add_static and comment out fallback catch-all handler 2025-05-09 00:30:43 +00:00
retoor 233c33ff33 fix: correct variable reference from user_template_path to user_static_path in static path resolution
The diff shows a one-line change in `src/snek/app.py` within the `Application` class. In the static path resolution logic, the code previously appended `user_template_path` to the paths list when a user-specific static path existed. This was a bug — the intended variable is `user_static_path`, which holds the actual static directory path returned by `get_static_path`. The fix ensures that the correct static path is used for user-specific static file serving, preventing potential 404 errors or incorrect file resolution when users have custom static directories.
2025-05-09 00:17:12 +00:00
retoor a50127be58 fix: correct session uid access from self.request to request in app.py
The session uid retrieval in the Application class was incorrectly referencing
self.request.session instead of the local request variable, causing potential
AttributeError when accessing user static paths.
2025-05-09 00:16:40 +00:00
retoor 3880121bdf fix: add missing self parameter to static_handler method in Application class 2025-05-09 00:15:57 +00:00
retoor 1d71a3604a feat: replace static file serving with per-user static handler supporting admin and user paths
Add `static_path` attribute to Application and introduce `static_handler` method that resolves static files from user-specific directories, admin directories, and the default static path. Implement `get_static_path` in UserService to return user-specific static directory path if it exists.
2025-05-09 00:08:43 +00:00
retoor d1e865716a fix: guard against None user_template_path before appending to list 2025-05-08 23:36:48 +00:00
retoor 0bad1afb95 feat: add is_admin field to user model and admin uid retrieval in mapper and service 2025-05-08 23:33:41 +00:00
retoor 19e8a804e3 feat: add tmux to apt install list in DockerfileUbuntu
The tmux terminal multiplexer package is appended to the existing RUN apt install command, ensuring it is available in the Ubuntu-based Docker image alongside other development and utility tools.
2025-05-08 02:05:15 +00:00
retoor 9dcd190ac4 fix: add ubuntu dependency to install target and source cargo env in bashrc 2025-05-08 01:32:43 +00:00
retoor d7bd8704c9 fix: replace gunicorn run command with direct .venv binary and add ubuntu docker target 2025-05-08 01:22:32 +00:00
retoor 32aab59086 fix: correct DockerfileUbuntu package list, add rustup install, and fix terminal init sequence
- Fix typo in DockerfileDrive COPY command (COpy -> COPY)
- Add xterm, valgrind, ack, irssi, lynx packages to DockerfileUbuntu
- Add rustup installation with nightly toolchain in DockerfileUbuntu
- Fix r binary move path in DockerfileUbuntu (add trailing /r)
- Remove CMD ["r"] from DockerfileUbuntu
- Replace terminal.html onopen handler: call fitAddon.fit() and send form feed instead of hardcoded green text
- Add $HOME/bin to PATH in .bashrc
- Add resize command to .bashrc for terminal geometry sync
- Delete terminal/.rcontext.txt file entirely
2025-05-08 01:20:06 +00:00
retoor 6d645d5225 fix: add missing timeout delay before focusing input field after upload 2025-05-07 23:10:56 +00:00
retoor 8cfcb9604d fix: add input field focus after layout update on Shift+G shortcut 2025-05-07 23:09:59 +00:00
retoor 09d7e273f6 fix: prevent shift+G layout update when input field is focused 2025-05-07 23:07:42 +00:00
retoor 2b9ae1bdec fix: delay input focus by 500ms after message send to prevent race condition
The focus call for the input field after sending a message is now wrapped in a setTimeout with a 500ms delay. This prevents the focus from being applied before the DOM has fully updated after the new message is rendered, ensuring the cursor reliably lands in the input field for the next keystroke.
2025-05-07 23:04:29 +00:00
retoor 524d86c8c9 feat: auto-focus input field after sending message in chat UI
When a message is sent via Enter key, the input field now automatically regains focus, allowing the user to continue typing without manually clicking back into the text area. This improves the upload and messaging flow by eliminating an extra interaction step.
2025-05-07 23:03:11 +00:00
retoor 6ab89e69f6 fix: correct variable name in Escape key handler from timeout to keyTimeout 2025-05-07 22:59:18 +00:00
retoor c2378a6ffa feat: add keyboard shortcut to scroll to last message on double Escape+G press
Implement a keydown listener that tracks Escape key state and counts subsequent 'G' presses. When Escape is held and 'G' is pressed twice within 300ms, the view scrolls to the last message in the messages container. Also handle Shift+G to trigger layout update via existing updateLayout function.
2025-05-07 22:56:55 +00:00
retoor 2a1b8059d2 feat: dispatch upload event and refocus input field after file upload completes 2025-05-07 22:31:21 +00:00
retoor 256f741633 feat: add clipboard paste and drag-drop file upload support to web interface
Implement file upload via clipboard paste events in the web textarea input, reading non-text clipboard items as blobs and feeding them into the existing upload-button component. Also add drag-and-drop event listeners to handle dropped files, enabling direct file uploads without manual file selection.
2025-05-06 21:27:25 +00:00
BordedDev f522e7583e feat: add uuid-based uid generation and per-user upload directories in security and upload modules
- Introduce UIDNS class and uid() function in security.py for deterministic UUID5 generation
- Refactor hash() and verify() with type hints, docstrings, and string-based salt handling
- Replace global UPLOAD_DIR constant in upload.py with dynamic per-user upload folder derived from session uid
- Update file path handling to use user-specific upload directory instead of hardcoded ./drive path
2025-05-06 21:17:08 +00:00
retoor 0ff7a62433 fix: replace global upload dir with per-user home folder path in UploadView 2025-05-06 21:16:03 +00:00
BordedDev 4164fbd877 feat: add paste and file drop support to web input handler
Implement clipboard paste event listener in initInputField that reads image files from clipboard and triggers upload via the upload-button component. Add drag-and-drop file drop support to the textarea input field for improved user experience.
2025-05-06 21:14:52 +00:00
retoor 842c8abdf7 feat: add uuid-based uid generation and type annotations to security module
Introduce a new `uid` function that generates UUID5 values from a namespace and optional input string, along with a `UIDNS` helper class. Update `hash` and `verify` functions with explicit type hints, docstrings, and change `DEFAULT_SALT` from bytes to string for consistency.
2025-05-06 21:02:09 +00:00
retoor 00fb955715 fix: handle missing pty module on Windows with graceful error message 2025-05-06 20:17:04 +00:00
retoor 677f97efcc fix: add setuptools package find config and package-data in pyproject.toml
The diff adds two new sections to pyproject.toml: [tool.setuptools.packages.find] with where = ["src"] and [tool.setuptools.package-data] with "*" = ["*.*"] to ensure proper package discovery and data file inclusion during build.
2025-05-06 20:00:07 +00:00
retoor 9f45d4774d feat: add CLI argument parsing and console script entry point for snek
Add argparse-based CLI with --port, --host, and --db_path options to __main__.py,
replacing hardcoded web.run_app call with a main() function. Register snek console
script in pyproject.toml pointing to snek.__main__:main.
2025-05-06 19:44:45 +00:00
BordedDev c53e2062fa refactor: replace chained boolean condition with all() for readability in PushView
Refactor the subscription body validation in `src/snek/view/push.py` to use `all()` with a list comprehension instead of a multi-line `and` chain, improving code clarity. Also normalize string quotes from single to double for `body["endpoint"]` and reformat the `requests.post` call to span multiple lines for consistency with project style.
2025-04-19 19:33:31 +00:00
retoor 4e65fc6c88 style: convert header layout to flexbox and add padding to chat-area section 2025-04-16 22:05:25 +00:00
retoor 986cf58909 feat: add chat-area CSS class to user profile section wrapper 2025-04-14 21:20:05 +00:00
retoor c8c81f2dce fix: remove fixed positioning from header on mobile viewports
The `position:fixed` declaration was removed from the header element within the
max-width 768px media query, allowing the header to scroll naturally with content
on smaller screens instead of remaining fixed at the top.
2025-04-14 21:16:52 +00:00
retoor dcbcd9de83 feat: add navigation sidebar with back button to user template
Add a "Navigation" section to the user sidebar template in `src/snek/templates/user.html`, including a "Back" link that triggers `window.history.back()` for improved user navigation flow.
2025-04-14 21:09:23 +00:00
retoor 96867d68cb fix: use authenticated user uid for profile property save in settings view 2025-04-14 21:00:05 +00:00
retoor 3e0e9ffceb feat: wrap avatar in anchor tag and fetch user by uid from match_info in user view
- In message.html template, moved the avatar `<img>` inside an `<a>` tag to make the user avatar clickable, linking to the user's profile page.
- In user.py view, changed `get()` to extract `user_uid` from request match_info and fetch the user record via `self.services.user.get(uid=user_uid)` instead of relying on `self.request['user']`, passing the uid to the template context.
2025-04-14 20:54:12 +00:00
retoor 6a29f53bdc feat: replace get-or-create logic with upsert in UserPropertyService.set
Replace the manual fetch-and-save pattern in the `set` method of `UserPropertyService` with a direct `upsert` call on the underlying mapper, using `user_uid` and `name` as the conflict keys. This eliminates the round-trip to check for an existing property before inserting or updating, and removes the dependency on the base service's `get`, `new`, and `save` methods for this operation.
2025-04-14 20:41:14 +00:00
retoor 892dba7d06 feat: add user profile page with sidebar and markdown rendering
Implement a new user view and template that displays user profiles with a sidebar containing profile and DM links, gist listing placeholder, and renders the user's profile content as markdown. The UserView class fetches the profile property from the user_property service and passes both the user record and profile content to the template.
2025-04-14 20:31:46 +00:00
retoor 65f6d0fe00 feat: add user profile page and link avatars to user html route
- Import and register UserView at /user/{user}.html in app router
- Wrap avatar img in message template with anchor to user profile page
- Remove monaco editor and header text from settings template, replace with simple logo block
2025-04-14 20:31:26 +00:00
retoor 145bcf19ab fix: resolve recursive call in user_property get and add profile form submission
- Changed UserPropertyService.set() to call super().get() instead of self.get() to avoid recursion
- Fixed UserPropertyService.get() to access "value" key from dict and added exception logging
- Updated settings/profile.html to include a proper POST form with profile textarea and submit button
- Added redirect to /web.html for authenticated users in IndexView.get()
- Refactored SettingsProfileView to handle POST form data directly, saving nick and profile properties
2025-04-13 21:31:52 +00:00
BordedDev a15c44b1b1 refactor: rename create_encrypted_payload to create_notification_info_with_payload in notification and push modules 2025-04-13 21:20:30 +00:00
BordedDev 69dabea653 feat: refactor push notification encryption to return headers and data dict
Refactor `create_encrypted_payload` to accept endpoint parameter and return a dictionary containing pre-built HTTP headers and encrypted payload data, eliminating manual header construction in the PushView caller. Remove unused `public_key_base64` certificate retrieval and inline header generation logic.
2025-04-13 21:14:01 +00:00
retoor 95190ec7c0 fix: require authentication for search user view by setting login_required flag 2025-04-13 18:28:15 +00:00
retoor e632c9952f fix: enforce uid presence alongside logged_in flag in BaseView auth check 2025-04-13 18:26:02 +00:00
retoor adaecd597a fix: return empty list instead of raising exception for empty search query in UserService.search 2025-04-13 17:10:10 +00:00
retoor 6a0b86d0bd fix: refactor terminal session to lazy process start with restart on websocket connect 2025-04-13 12:47:10 +00:00
retoor dc4d979b4f chore: add rule to be rude but functional and remove working directory instruction 2025-04-13 09:51:32 +00:00
retoor ab46d95810 feat: add .rcontext.txt with coding assistant rules and work procedure
Introduce a new configuration file defining the assistant's identity, capabilities, and operational guidelines. The file establishes core facts such as the assistant's name (`R`), creator (`retoor`), tool usage rules (e.g., `linux_terminal_execute_interactive` for interactive editors, `linux_terminal_execute` for `apt` commands), and coding practices (no comments, no `sudo`, defensive coding). It also outlines a 11-step work procedure emphasizing file investigation, complete feature implementation, Makefile generation, and aggressive file management with overwrite checks.
2025-04-13 09:46:40 +00:00
retoor d356fe2641 fix: replace cp with mv in DockerfileUbuntu and add r_update function to .bashrc for automatic binary refresh 2025-04-13 09:39:12 +00:00
retoor d2912e06a3 feat: add StatsView endpoint returning cached stats as JSON response
Implement a new StatsView class under src/snek/view/stats.py that exposes a GET handler. The handler retrieves statistics from the application cache, serializes them with total count and indented JSON formatting, and returns the result with application/json content type.
2025-04-13 03:08:20 +00:00
retoor 65074b8f6a feat: add cache stats tracking and expose via /stats.json endpoint
Introduce per-key hit/miss/delete counters in the Cache class, update them on every get/set/delete operation, and expose the aggregated stats through a new StatsView at /stats.json. Also add a __repr__ method to the Validator model for easier debugging.
2025-04-13 03:06:53 +00:00
BordedDev 697935c9de feat: refactor push notification service worker and key management with async registration
Replace commented-out push handler with a full async `registerServiceWorker` function that fetches the VAPID public key, subscribes the push manager, and POSTs the subscription object to `/push.json`. Remove unused helper functions (`arrayBufferToBase64`, `requestNotificationPermission`, `subscribeUser`) and top-level fetch calls. Convert notification key file paths from string constants to `pathlib.Path` objects, replacing `os.path.isfile` checks with `.exists()` and `open`/`write` calls with `.read_bytes()`/`.write_bytes()`. Update the notification test payload to use relative icon and URL paths. Fix the app.html onclick handler to call the renamed `registerNotificationsServiceWorker()` function.
2025-04-13 01:19:28 +00:00
BordedDev 3595e40851 feat: add notification body and icon support to push subscription handling
- Extend service-worker push event to parse and display notification body, icon, and title from push data payload
- Add AESGCM and HKDF imports to notification module for future encryption support
- Refactor notification key file path constants to use double quotes for consistency
- Update push view to include notification body in subscription response and validate request fields
2025-04-12 12:25:57 +00:00
retoor 0405088d94 feat: add get_user_uids method and fix socket broadcast with error handling
- Add async generator `get_user_uids` to ChannelMemberService for direct user_uid queries
- Fix `broadcast` in SocketService to use new method instead of full channel_member find
- Remove early return in `subscribe` method to allow actual subscription logic
- Improve error logging in RPC view and socket send/close methods
2025-04-10 11:34:32 +00:00
retoor 35afb8922c fix: uncomment sidebar terminal section and restore channel list rendering
Remove HTML comment markers around the "Terminals" heading and link in sidebar_channels.html, and close the comment block before the channels section so both terminal and channel lists are displayed.
2025-04-10 06:37:05 +00:00
retoor 4bad0fe047 chore: reorder import statements to place third-party before local in multiple modules 2025-04-09 13:21:23 +00:00
retoor aba5a94026 chore: remove unused imports and fix formatting across multiple modules 2025-04-09 13:12:34 +00:00
retoor a17e1c7ad9 feat: enable query string video ID extraction and remove trailing semicolon from embed style
Uncommented the `v=` parameter parsing branch in `embed_youtube` to correctly extract video IDs from YouTube URLs containing query strings, replacing the previous fallback that only handled path-based IDs. Also removed the trailing semicolon from the `style` attribute in the iframe embed template for consistency with HTML attribute formatting conventions.
2025-04-09 09:09:00 +00:00
retoor cf4056175a feat: simplify youtube url parsing and add block display style to embed iframe 2025-04-09 09:07:09 +00:00
retoor 326bcdd6cc feat: fix operator precedence in youtube link detection by grouping or conditions
The diff corrects a logical bug in `embed_youtube` within `src/snek/system/template.py` where the `and` operator had higher precedence than `or`, causing the condition to match links starting with "https://www.you" only when `v=` was present, but also matching any link with `si=` regardless of domain. By wrapping the two `or` conditions in parentheses, the fix ensures both `v=` and `si=` parameters are only checked against youtube links.
2025-04-09 09:03:39 +00:00
retoor 4733f8e194 feat: parse youtube video id from v= and si= query params in embed_youtube
Replace naive split-on-slash video name extraction with proper query parameter parsing for v=, si=, and t= parameters. Handle cases where href contains si= without v= by constructing embed URL with ?v= prefix from the last path segment. Remove commented-out legacy parsing code and adjust iframe src construction accordingly.
2025-04-09 09:02:45 +00:00
retoor abfd2600a2 feat: remove wrapping div from youtube embed template in embed_youtube function 2025-04-09 08:59:09 +00:00
retoor db405c2148 feat: wrap youtube iframe embed in div and remove inline margin style 2025-04-09 08:56:52 +00:00
retoor 53a7bdae1d feat: remove broken youtube link detection condition in embed_youtube
The diff removes an incorrect `or` condition that checked for "si=" in the href,
which caused false positives and broke the video name extraction logic for
standard YouTube URLs containing query parameters.
2025-04-09 08:55:30 +00:00
retoor 2aa788d5a2 feat: add inline style to center youtube embed iframe in template.py 2025-04-09 08:52:39 +00:00
retoor 1d8c8c7a73 feat: replace youtube url parsing with simple path extraction in embed_youtube
Simplify video ID extraction by taking the last path segment from the href instead of parsing query parameters for v=, si=, and t=, removing the broken multi-parameter concatenation logic.
2025-04-09 08:46:53 +00:00
retoor 15bb74a09e feat: fix youtube embed extraction to handle v=, si=, and t= params separately 2025-04-09 08:43:46 +00:00
retoor 1fd392fbaa fix: correct operator precedence in YouTube URL detection for embed generation 2025-04-09 08:35:15 +00:00
retoor 338773aa3d feat: extend YouTube embed to support shared links with si= parameter and preserve full query string
The diff modifies `embed_youtube` in `src/snek/system/template.py` to handle YouTube share URLs that use `si=` instead of `v=`, and changes the video ID extraction to pass the entire query string (including `si=`) to the embed iframe src. A `<br />` tag is also prepended to the embed template for spacing.
2025-04-09 08:34:57 +00:00
retoor 1f5cd4a7e9 feat: add asyncio import and time_cache decorators, enhance debug middleware with response logging 2025-04-08 19:32:18 +00:00
retoor f0836d13de feat: add debug middleware and enable LOCK/UNLOCK routes in WebdavApplication
- Added aiohttp middleware `debug_middleware` that logs request method, path, and headers
- Injected `debug_middleware` into the application's middleware stack via `middlewares` parameter
- Set `self.relative_url` attribute to "/webdav" and added debug print of router
- Uncommented and enabled LOCK and UNLOCK route registrations for filename-based paths
2025-04-08 18:31:15 +00:00
retoor b53bea5906 feat: add UserPropertyService with set, get, and search methods for user properties
Implement a new service class `UserPropertyService` in `src/snek/service/user_property.py` that provides CRUD operations for user properties. The `set` method creates or updates a property with JSON-serialized values, `get` retrieves and deserializes a property by user UID and name, and `search` performs case-insensitive partial matching on property names with an empty query guard returning an empty list.
2025-04-08 03:01:27 +00:00
retoor 30c0fadbc6 feat: add user property model methods and migrate executor to app startup lifecycle
Introduce UserPropertyService and wire it into service registry; add get_property, has_property, set_property methods to UserModel for dynamic user attributes. Move ThreadPoolExecutor initialization from module-level to Application.prepare_asyncio startup hook, ensuring executor is bound to app instance. Include IPython in dependencies, fix missing newline at end of app.js, adjust sidebar logo font-size from 12px to 14px, and add header_text block to search_user and threads templates.
2025-04-08 02:20:28 +00:00
retoor d21406bdc5 fix: replace web manifest icon references with correctly sized 192 and 512 png assets to resolve firefox android instability 2025-04-07 11:24:13 +00:00
BordedDev 46b4630c9a feat: add notification button and fix push subscription encoding for chrome
- Export registerServiceWorker function in push.js and add debug logging for key data
- Add notificationclose event listener and fetch cache-first strategy in service-worker.js
- Add notification button to app.html template calling registerServiceWorker
- Fix public key encoding from base64 to urlsafe_b64encode in push.py for chrome compatibility
2025-04-06 22:36:49 +00:00
BordedDev 5293d483c4 fix: replace manifest icon paths with correctly sized 192 and 512 png assets
The manifest.json previously referenced a single icon file (snek1.png) for both 192x192 and 512x512 sizes, causing browser instability warnings due to mismatched dimensions. This commit adds properly sized snek192.png and snek512.png images and updates the manifest to point each icon entry to its corresponding size-specific asset, resolving the Firefox Android manifest validation issue.
2025-04-06 21:47:18 +00:00
retoor 67b7dc4cab feat: add user settings profile with form, model, mapper, and templates 2025-04-03 06:35:25 +00:00
retoor a1038ee72b feat: add auth middleware, user property model, and settings views with navigation link
Introduce an authentication middleware that attaches the user object to requests based on session data, register the UserPropertyMapper and UserPropertyModel in the service layer, replace the monolithic SettingsView with dedicated SettingsIndexView and SettingsProfileView, update the settings template to remove inline Monaco editor, delete the old sidebar_settings partial, and wire the settings gear icon in the app navigation to /settings/index.html.
2025-04-03 06:34:25 +00:00
retoor 86d54768a3 fix: set nav to full width and remove redundant float style in header
- Added `width:100%` to anchor element in base.css to ensure full-width layout
- Removed `float:right` from nav element in app.html to prevent layout overflow issues
2025-04-02 13:02:55 +00:00
retoor 976f0eeb68 feat: add request id to context and cache-bust static assets with rid query param 2025-04-02 12:55:18 +00:00
retoor 7fddff68bc fix: wrap channel_message query in try-except to handle missing channel gracefully 2025-04-02 08:52:02 +00:00
retoor 090dbd4598 fix: adjust generic-form layout for mobile by expanding container to full viewport width 2025-04-01 19:23:10 +00:00
retoor 35331e1fa7 feat: reduce main container width from 600px to 500px for better mobile fit
Adjust the .container width in style.css to 500px, improving layout
compatibility on narrower phone screens while preserving padding and
border-radius.
2025-04-01 19:09:58 +00:00
retoor 0f84201ad9 fix: remove dark background colors and adjust layout for mobile-friendly login and register pages 2025-04-01 19:06:07 +00:00
retoor 42d7713661 feat: add flexbox layout to footer with space-between alignment and padding
Add display:flex with justify-content:space-between and align-items:center to the footer element, along with 10px vertical and 20px horizontal padding, to create a responsive two-column footer layout for the chat application's base stylesheet.
2025-04-01 18:28:56 +00:00
retoor 50afad74f9 fix: remove hidden chat-window element from web template
The diff removes the `<chat-window>` element that was hidden with `display:none` from the web.html template, likely because it is no longer needed or has been replaced by a different component.
2025-04-01 18:23:35 +00:00
retoor b9de37d05a fix: reduce .message-list and .message-list-inner height from 200px to 10px in base.css 2025-04-01 18:20:18 +00:00
retoor f74fcc3ab3 feat: add sticky header and footer layout for mobile viewports under 600px
Add responsive CSS media query for max-width 600px making header position sticky with block display for logo, and make chat-input footer sticky. Change chat-input container from div to footer element in web.html template for semantic HTML structure.
2025-04-01 18:08:01 +00:00
retoor 0c8c77ae1d feat: add settings page with monaco editor and fix webdav prop for directories
Add a new SettingsView at /settings.html with a sidebar navigation for profile, notifications, and privacy sections. The settings page includes a Monaco code editor for profile description editing. Also fix the WebDAV propfind handler to only include contenttype and getcontentlength properties for files, preventing errors when these properties are requested on directories.
2025-04-01 14:21:29 +00:00
retoor 604b50e582 fix: suppress unhandled exception during home folder creation in get_home_folder
The `folder.mkdir(parents=True, exist_ok=True)` call in `UserService.get_home_folder` could raise an unhandled exception (e.g., permission errors, filesystem issues) when creating the user's home directory. Wrapped the call in a try-except block to silently ignore failures, preventing crashes during user operations while maintaining existing behavior for already-existing folders.
2025-03-30 07:34:13 +00:00
retoor 98e3df5a40 fix: comment out LOCK and UNLOCK route registrations in WebdavApplication 2025-03-30 05:02:35 +00:00
retoor 03272f0bd0 fix: change user home folder path from ./home to ./drive and comment out lock registration in webdav 2025-03-30 04:54:25 +00:00
retoor ee895bbc7d feat: extract PROPFIND response generation into dedicated create_node method
Extract the XML response node creation logic from the PROPFIND handler into a new async create_node method on WebdavApplication, reducing handler complexity and enabling reuse for future multi-node responses. The method handles href path normalization, resource type detection, quota reporting, and metadata population including creation date, last modified, content type, and lock discovery.
2025-03-29 09:50:40 +00:00
retoor 5396b28396 feat: change user home folder path from drive to home directory
The user home folder path is updated from `./drive/{user_uid}` to `./home/{user_uid}` in the `get_home_folder` method of `UserService`, aligning with the new WebDAV storage layout.
2025-03-29 06:27:56 +00:00
retoor 6c275bfb33 feat: comment out session-based auth in WebdavApplication.authenticate and fall through to Basic auth
The session-based authentication block in `WebdavApplication.authenticate` (lines 48-56) has been fully commented out, including the `session` lookup, `user` retrieval via `services.user.get`, and `home` folder assignment. The method now unconditionally proceeds to parse the `Authorization` header for Basic credentials, effectively switching the WebDAV endpoint from session-based to HTTP Basic authentication.
2025-03-29 06:19:08 +00:00
retoor 4ac7321229 feat: add initial WebDAV server implementation with aiohttp and file operations
Implement a complete WebDAV application module under src/snek/ including:
- WebdavApplication class with route handlers for OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH, LOCK, UNLOCK methods
- Authentication integration via session-based user lookup and home folder resolution
- Lock management infrastructure with in-memory locks dictionary
- XML parsing support using lxml for PROPFIND/PROPPATCH request bodies
- Async file I/O operations via aiofiles for GET/PUT handlers
- MIME type detection and datetime formatting for resource metadata
- Base64 encoding support for lock token generation
- Logging configuration at DEBUG level for development
2025-03-29 06:15:53 +00:00
retoor 637d7929d3 feat: integrate WebDAV application and add lxml dependency for XML parsing
Add WebdavApplication to the app router and include lxml in project dependencies to support WebDAV protocol functionality. Also perform minor code style cleanups across multiple modules (quote normalization, import reordering, formatting adjustments) without behavioral changes.
2025-03-29 06:13:23 +00:00
retoor 8586705a94 fix: add explicit width and height attributes to avatar img tag to restore visibility 2025-03-29 00:21:33 +00:00
retoor 9e9f317853 feat: replace runtime ubuntu image with custom snek_ubuntu and move r binary into Dockerfile
Add DockerfileUbuntu that pre-installs r binary and all required system dependencies, removing the need for per-container apt installs and binary copy from .bashrc. Update terminal view to use the new snek_ubuntu image instead of ubuntu:latest. Fix terminal session buffer check to use self.history instead of self.buffer. Add docker build target to Makefile.
2025-03-28 01:41:57 +00:00
retoor 51d0ca1bc5 fix: correct missing await before async task creation in ChatService
The diff shows a single-character change in `src/snek/service/chat.py` where `async self.app.create_task(...)` was corrected to `await self.app.create_task(...)`, fixing a syntax error that would have prevented the notification task from being properly awaited.
2025-03-27 20:14:01 +00:00
retoor 07ac6548b6 fix: move notification task creation after socket broadcast in chat service
The notification task creation was placed before the socket broadcast, causing
potential ordering issues where notifications could be sent before the message
was broadcasted to active channel members. This commit reorders the operations
to ensure the socket broadcast completes before the notification task is created.
2025-03-27 20:12:19 +00:00
retoor af48c6522b fix: convert notification and broadcast calls to non-awaited task creation
The diff modifies two lines in `src/snek/service/chat.py` within the `ChatService` class. The first change removes the `await` keyword from the notification channel message creation, wrapping it instead with `async self.app.create_task()`. The second change removes the outer `await self.app.create_task()` wrapper from the socket broadcast call, making it a direct synchronous call. These adjustments alter the concurrency model for both notification delivery and socket broadcasting operations.
2025-03-27 20:11:02 +00:00
retoor 0acf87b506 feat: add task execution timing and task_done tracking in Application worker loop
Add time measurement around task execution in the worker loop of Application class, printing elapsed seconds for each completed task. Also call self.tasks.task_done() after successful task completion to properly signal task queue completion tracking.
2025-03-27 20:07:04 +00:00
retoor 87fed5c274 feat: wrap task execution in explicit database transaction begin/commit
Add explicit `self.db.begin()` before task execution and `self.db.commit()` after completion in the `task_runner` method of `Application` class. Previously tasks ran without explicit transaction boundaries, relying on implicit database behavior. This change ensures each task is executed within a well-defined transaction scope, providing proper isolation and atomicity guarantees for database operations performed during task processing.
2025-03-27 20:01:17 +00:00
retoor e4432e9147 feat: remove manual db transaction begin/commit from rpc message handler
The diff removes explicit `self.app.db.begin()` and `self.app.db.commit()` calls from the RPC message processing loop in `src/snek/view/rpc.py`, suggesting transaction management is now handled elsewhere or implicitly.
2025-03-27 20:00:34 +00:00
retoor 4e5d99f696 feat: replace polling-based task runner with asyncio.Queue and fix broadcast return type
Replace the list-based task storage and polling loop with an asyncio.Queue for proper async task scheduling, eliminating the 0.1s sleep and IndexError handling. Also change SocketService.broadcast to return boolean instead of integer count, and add explicit exception logging in task_runner.
2025-03-27 19:56:35 +00:00
retoor cae698fb30 feat: add async task queue and refactor chat broadcast to non-blocking execution
Introduce a lightweight in-process task queue on the Application class with `create_task` and `task_runner` methods, enabling deferred execution of background operations. Refactor `ChatService.send` to enqueue the socket broadcast as a task instead of awaiting it directly, and move the `last_message_on` channel update before the broadcast to ensure timestamp consistency. The return value is simplified from `sent_to_count` to `True` since the broadcast result is no longer synchronously available.
2025-03-27 19:41:04 +00:00
retoor 215ed416ea feat: wrap rpc handler execution in explicit database transaction begin/commit 2025-03-27 19:20:48 +00:00
retoor da4ec359c7 fix: wrap notification creation loop in explicit db transaction
The diff adds explicit database transaction boundaries around the notification creation loop in `NotificationService`. A `self.app.db.begin()` call is inserted before iterating over channel members, and a `self.app.db.commit()` is added after the loop completes. This ensures that all notification saves within the loop are executed atomically, preventing partial writes if an exception occurs during processing of individual channel members.
2025-03-27 19:10:05 +00:00
retoor 97db9b657f fix: add missing pass to submit method in BaseFormView to prevent syntax error 2025-03-27 16:30:06 +00:00
retoor dc21d63f3b feat: add mark_as_read method and remove debug print statements across services and views 2025-03-27 16:28:54 +00:00
retoor a6f3138f68 fix: skip notification insert for deleted user in channel member update 2025-03-27 16:15:39 +00:00
retoor 4397fed778 feat: add last message user color and unread count to channel sidebar and RPC response
- Extend channel sidebar template to display per-channel unread message count and color-coded names based on the last message author's color
- Add `color` and `new_count` fields to channel objects in both server-rendered sidebar and RPC `getChannels` endpoint
- Optimize `ChannelModel.get_last_message` to fetch only `uid` first then retrieve full model, reducing data transfer
- Reset `new_count` to zero when a user views a channel in `WebView`
- Add debug prints for RPC responses and exceptions in `RPCView`
2025-03-27 16:10:02 +00:00
retoor 7c76a41543 fix: increase thread pool workers and re-enable default executor for websocket tasks 2025-03-23 17:52:12 +00:00
retoor 400c4cf433 fix: replace asyncio subprocess with pty-based subprocess for websocket terminal sessions 2025-03-23 17:46:10 +00:00
retoor 2e0af5511f fix: correct drive item URL construction to use item.extension instead of drive_item.extension
The drive item URL was incorrectly referencing `drive_item.extension` which does not exist on the record object, causing AttributeError when building item URLs. Changed to use `item.extension` from the async iterated DriveItem object to properly construct the download path.
2025-03-23 13:38:53 +00:00
retoor 8ab291691f feat: add url field to drive item records for direct file access
In both the single drive retrieval and the user drives listing endpoints, each drive item record now includes a computed 'url' field constructed from the item's uid and extension, enabling clients to directly access the binary file at '/drive.bin/<uid>.<ext>'.
2025-03-23 13:08:10 +00:00
retoor 3623ec13b4 feat: add drive view with extension-based categorization and async terminal process management
- Register DriveView at /drive.json and /drive/{drive}.json endpoints in app router
- Add DriveModel.name field and async items property yielding drive items
- Add DriveItemModel.extension and mime_type properties using mimetypes
- Implement DriveService.get_drive_name_by_extension and get_drive_by_extension for automatic drive categorization
- Store file extension in DriveItemService during item creation
- Refactor TerminalSession to use async subprocess with lazy process initialization and multi-socket output buffering
- Move database preparation and service initialization to async startup hook with await on drive.prepare_all
2025-03-23 12:57:20 +00:00
retoor dd3b45e31f chore: add git to apt install list in bashrc for version control support 2025-03-23 02:16:43 +00:00
retoor 33a3d5f53d feat: add output buffer and catch-up replay for late-joining terminal websocket clients
Introduce a per-session byte buffer that accumulates terminal output up to 10KB, allowing newly connected websocket clients to receive the last line of buffered output before the live stream begins. Also improve error handling by terminating the subprocess and closing all active websockets on read failure, and adjust terminal HTML layout to use 90% width with hidden scrollbar for a cleaner full-height display.
2025-03-23 01:53:36 +00:00
retoor 2d5aa8eed7 feat: add ThreadPoolExecutor with 100 workers as default asyncio executor 2025-03-23 01:16:24 +00:00
retoor c9dd5e41d1 fix: correct file permission for /usr/local/bin/r from -x to +x
The chmod flag for /usr/local/bin/r was incorrectly set to -x (remove execute),
preventing the binary from being run. Changed to +x to grant execute permission,
allowing the r binary to be invoked as intended.
2025-03-22 21:46:19 +00:00
retoor be74b1a991 fix: remove execute permission from r binary and add vim htop to apt install in .bashrc 2025-03-22 20:01:15 +00:00
retoor 978a67819e fix: correct file permission flag from remove-executable to add-executable for /usr/local/bin/r 2025-03-22 19:55:57 +00:00
retoor 99cefc6b5f feat: add dm channel sidebar with last message sorting and user context to template rendering
Add channel subscription loading in render_template to populate sidebar with
private DM channels showing other user's nick, sort channels by last_message_on
descending, and inject user object into template context for authenticated sessions.
2025-03-22 19:42:38 +00:00
retoor cb2c72ce1d feat: add terminals section to sidebar and include sidebar in terminal template
Add a "Terminals" heading with an Ubuntu link above the channels list in the sidebar template, and wrap the channels section in a conditional check for non-empty channels. Replace the placeholder "Reboot" text in terminal.html with an include of the sidebar_channels template to render the full sidebar.
2025-03-22 19:04:20 +00:00
retoor 3ed651d23f feat: add in-browser terminal with per-user docker sessions and drive mount
Add a web-based terminal accessible at /terminal, backed by per-user ephemeral Docker containers. Each user gets an isolated Ubuntu container with their drive directory mounted at /root, memory/cpu limits, and a prepared .bashrc/.profile. The terminal uses xterm.js over a WebSocket binary transport for full ANSI support. Also migrate the base Docker image from Alpine to Debian bookworm (python:3.14.0a6-bookworm), remove wkhtmltopdf dependencies, and fix the compose volume path from /media/storage/snek/molodetz.nl/drive to /media/storage/snek.molodetz.nl/drive.
2025-03-22 18:57:39 +00:00
retoor 20c2209162 feat: add docker socket mount and terminal websocket view to snek service
- Add docker package to Dockerfile for container management capabilities
- Mount docker socket and storage volumes in compose.yml with privileged mode
- Register TerminalView and TerminalSocketView routes in app router for web terminal access
2025-03-22 17:16:36 +00:00
retoor 0614251626 refactor: consolidate session assignment into single update call across register views
Replace four separate session key assignments with a single dict update in both RegisterView and RegisterFormView, and fix missing trailing newline in login.py.
2025-03-20 01:12:00 +00:00
retoor 377937f955 fix: change dump script output from nested JSON to flat text file with formatted messages 2025-03-18 21:06:28 +00:00
retoor af6aa9ce1c feat: convert dump script to async and write output to dump.json
Convert dump_public_channels to async function with asyncio.run entry point, add fix_message helper to transform message records with user lookup, filter channels by tag='public', order messages by created_at, and write formatted JSON output to dump.json file instead of stdout. Also suppress verbose cache debug prints in Cache class.
2025-03-18 00:13:25 +00:00
retoor ca5490d558 feat: add make dump target and dump script for exporting public channels with messages
Introduce a new `dump` make target that invokes `snek.dump` module, and implement the `dump_public_channels` function which iterates over non-private, listed channels, collects their metadata and associated messages, and outputs the result as formatted JSON to stdout.
2025-03-17 23:46:32 +00:00
retoor f1f118963f feat: add Promise.withResolvers polyfill and load it in app and base templates
Create a new polyfill script at src/snek/static/polyfills/Promise.withResolvers.js that provides a fallback implementation of Promise.withResolvers for environments lacking native support. Include the polyfill as a module script in both app.html and base.html templates, ensuring it is loaded before app.js to guarantee availability at runtime.
2025-03-17 21:09:55 +00:00
BordedDev 3188b180df fix: revert multi-line formatting of umami script tag in base.html 2025-03-17 21:06:52 +00:00
BordedDev bf8ca3c69b feat: add Promise.withResolvers polyfill and include it in app and base templates
The polyfill provides a static method on Promise that returns an object with resolve and reject functions exposed, enabling external resolution of a promise. It is loaded as a module script in both app.html and base.html to ensure compatibility before app.js runs.
2025-03-17 21:05:46 +00:00
retoor ff34706546 feat: replace static logo with dynamic header text block in app layout
- Split header padding into individual properties (top, left, right) in base.css
- Reduce logo font size from 1.5em to 1.2em in base.css
- Hide static "Snek" logo div with inline display:none in app.html
- Add new logo div with block header_text template block in app.html
- Move chat header h2 from web.html into the new header_text block in app.html
2025-03-17 16:01:01 +00:00
retoor 37105cbe9d fix: add block end to scrollIntoView options in chat scroll logic 2025-03-17 08:15:49 +00:00
BordedDev 95643d717e feat: add push notification infrastructure with VAPID key generation and service worker registration
Introduce initial push notification support by adding a new `notification.py` module for VAPID key pair generation (EC P-256), a `PushView` endpoint serving the public key, and a `push.js` client that fetches the key and subscribes the service worker with `userVisibleOnly` flag. The service worker push listener is refactored to check notification permission and handle click events. The `app.html` template now includes the push script as a module, and the application startup calls `get_notifications()` to ensure keys exist. Note: FCM-based push on Chrome/Opera remains non-functional due to unresolved compatibility issues.
2025-03-17 02:16:39 +00:00
retoor 33496b1134 feat: add umami analytics tracking script to all main templates
Inject the umami analytics script with website ID d127c3e4-dc70-4041-a1c8-bcc32c2492ea into app.html, base.html, and index.html templates for visitor tracking
2025-03-16 18:46:15 +00:00
retoor 4c238498ef fix: remove background-color from upload-button in chat-input stylesheet 2025-03-16 05:45:31 +00:00
retoor f6c0a2b367 feat: change upload button background to black and progress bar to orange
Replace the page background (#f4f4f4) and upload button background (#f05a28) with solid black (#000000), and invert the progress bar overlay from semi-transparent white to the previously used orange (#f05a28) for visual contrast.
2025-03-16 05:42:03 +00:00
retoor c3a8b5da08 fix: extract EventHandler and Socket classes into separate modules for socket reconnection fix 2025-03-16 05:02:03 +00:00
BordedDev 24ce2ceb83 style: normalize import spacing and object literal formatting in app.js 2025-03-16 04:03:05 +00:00
BordedDev 4e9c8c55bb refactor: extract EventHandler and Socket classes into separate modules from app.js 2025-03-16 03:55:01 +00:00
retoor 66bae15d72 feat: convert frontend classes to ES modules and add type="module" to script tags
Convert all JavaScript classes in app.js and schedule.js to ES module exports, add import statements in inline scripts, and update all script tags across templates to use type="module" attribute. This enables proper module scoping and dependency management for the frontend codebase.
2025-03-16 01:40:06 +00:00
BordedDev e12d3b7c5d feat: convert JavaScript classes to ES modules with export/import across app and templates
Convert all JavaScript classes in app.js and schedule.js from implicit globals to ES module exports, add import statements in template scripts for the app instance, and mark all script tags with type="module" to enable module scoping. Also fix a potential null reference in web.html by using optional chaining on lastMessage.scrollIntoView and hoist the lastMessage variable to outer scope for proper closure behavior.
2025-03-15 18:10:52 +00:00
retoor 66b912ac39 feat: add shared input focus styles and import them across all pages
Create new shared.css with consistent input/textarea focus outline and placeholder transition effects. Import shared.css into both base.css and style.css to apply the unified input styling across all pages, replacing previously scattered per-page implementations.
2025-03-15 15:27:38 +00:00
BordedDev 22d13d4385 feat: extract shared input focus styles into shared.css and import on all pages
Create a new shared.css file containing consistent focus ring and placeholder opacity transition rules for input and textarea elements. Import shared.css into both base.css and style.css to apply the updated input styling across all pages, replacing previously scattered or missing focus indicators.
2025-03-15 14:53:24 +00:00
retoor a1902fbfb3 fix: reduce updateTimes interval from 1000ms to 30000ms in web.html template
The setInterval call for the updateTimes function was changed from 1 second to 30 seconds,
reducing the frequency of time display updates in the web interface to lower client-side
processing overhead and network requests.
2025-03-14 22:02:58 +00:00
retoor 6163f8f8d4 fix: prepend blockquote prefix to quoted message in reply function 2025-03-13 19:36:14 +00:00
retoor fafd14bf72 feat: add 10px top padding to main content area for spacing consistency
The main content container in base.css now includes a padding-top of 10px to improve vertical spacing between the top edge and the content, ensuring consistent layout across pages.
2025-03-13 18:52:03 +00:00
retoor ac4e65f4d3 feat: replace all background colors with pure black and remove border separators in base.css 2025-03-13 18:51:01 +00:00
retoor 210cbc598c fix: correct time display to use container timestamp instead of message timestamp in web template 2025-03-13 18:21:38 +00:00
retoor 8b1a691e33 feat: add reply button to messages that populates textarea with quoted text 2025-03-13 18:15:10 +00:00
retoor 01d136598a feat: add Content-Disposition header for downloads and use UUID filenames for uploads
Add `Content-Disposition: attachment` header to file download responses so browsers prompt for download instead of displaying inline. Replace user-provided filenames with UUID-based names on upload to prevent path traversal attacks and filename collisions.
2025-03-11 09:10:50 +00:00
retoor b1ba65bb41 fix: add chat-area CSS class to search user container div 2025-03-10 10:53:54 +00:00
retoor 70ac495548 fix: handle None values in threads sort by last_message_on 2025-03-09 19:38:14 +00:00
retoor 2f1be0eb11 feat: preload form structure JSON into login and register templates and autofocus first input
Add `preloaded-structure` attribute to `<generic-form>` elements in both `login.html` and `register.html`, passing the serialized form JSON from the view. In `generic-form.js`, normalize CSS indentation from 10-space to 2-space tabs. This enables server-side form definition to be rendered immediately on page load without an initial JSON fetch, and the autofocus behavior targets the first input element in the preloaded structure.
2025-03-09 18:37:34 +00:00
BordedDev 7f9273d9dc fix: add missing semicolon to loadForm call in GenericForm URL handler 2025-03-08 20:18:20 +00:00
BordedDev ef8ee90276 feat: preload form structure into login and register templates and autofocus first input
Add `preloaded-structure` attribute to `<generic-form>` elements in both login and register templates, passing the serialized form JSON from the server. This enables instant form rendering without an additional fetch. Also implement autofocus logic in `generic-form.js` to focus the first input field on page load, improving UX for authentication flows.
2025-03-08 20:04:32 +00:00
retoor e71bbd4c85 fix: replace fallback sort key from 'zzzzz' to empty string in channel sorting 2025-03-08 19:29:48 +00:00
retoor 3148bf6ef7 fix: handle None last_message_on in channel sort key to prevent TypeError 2025-03-08 19:29:33 +00:00
retoor ea6ea65396 feat: split sidebar into public and private channel sections with dm tag detection
Add conditional rendering in sidebar_channels.html to separate channels based on the `is_private` flag, displaying public channels under a "Channels" heading and private ones under a "Private" heading. In web.py, set `is_private` to true when the parent channel's tag equals "dm", enabling the template logic to distinguish direct message channels from regular ones.
2025-03-08 19:27:05 +00:00
retoor 66b1946e07 feat: add last_message_on field and sort channels by recency in WebView
Retrieve last_message_on from parent channel object for each subscribed channel and sort the channels list in descending order by this timestamp before rendering the template.
2025-03-08 19:21:02 +00:00
retoor c7a062185c feat: add back-form layout and roundness to images, improve login/register templates
- Create back-form.css with grid layout for back button and form overlay
- Add border-radius: 20px to images, videos, iframes, and divs in message-content
- Add focus outline and placeholder transition to GenericField inputs
- Dispatch change event on Enter key in GenericField
- Add meta tags, title blocks, and head blocks to base.html, login.html, and register.html
- Wrap login/register forms in back-form div for consistent layout
2025-03-08 18:53:02 +00:00
BordedDev b349db3f97 feat: redesign search user results as thread cards with avatar and metadata
Replace the plain list-based user search results with a rich thread-style card layout
featuring avatar, nick, color-coded author, and last ping timestamp. Update the backend
view to pass user record objects and current_user context to the template.
2025-03-08 18:40:43 +00:00
BordedDev a504af4c6f style: revert indentation from 4-space to 2-space in base.css across all selectors 2025-03-08 18:35:22 +00:00
BordedDev f36b94e46b feat: wrap back button and form in grid container with new back-form.css stylesheet
Add a new CSS file `back-form.css` defining a two-column grid layout for the back button and generic form, with the button positioned at (1,1) with 30px margins and z-index 1. Update both `login.html` and `register.html` to include the stylesheet in the head block and wrap the existing `fancy-button` and `generic-form` elements in a `div.back-form` container, replacing the previous standalone button placement.
2025-03-08 18:07:09 +00:00
BordedDev 852d949d7f feat: add head block placeholder to base template for child template injection
Insert an empty Jinja2 block named 'head' inside the <head> element of base.html, enabling child templates to inject custom meta tags, styles, or scripts before the closing head tag.
2025-03-08 17:59:00 +00:00
BordedDev ba39d16635 style: add focus outline and placeholder opacity transition to form input fields 2025-03-08 17:56:55 +00:00
retoor def4a1ba3c feat: sort threads by last_message_on descending in ThreadsView
Add a sort operation to the threads list before rendering the template, ordering threads by their last_message_on timestamp in descending order so the most recently active threads appear first.
2025-03-08 17:41:37 +00:00
BordedDev 8855e40d6d feat: add theme-color, application-name, and author meta tags to base.html head
Add six new meta elements to the HTML head in base.html: theme-color set to black,
application-name and description both set to "Snek chat by Molodetz", author set to
"Molodetz", keywords including "snek, chat, molodetz", and color-scheme set to dark.
Also insert a blank line after the title block for improved readability.
2025-03-08 17:25:15 +00:00
BordedDev 64fe00b161 fix: dispatch change event on blur to sync autocomplete with form state 2025-03-08 17:09:14 +00:00
BordedDev 9c844d9f30 feat: add default page title and specific titles for login and register templates
Set a default "Snek chat by Molodetz" title in base.html and override it with
"Login - Snek chat by Molodetz" and "Register - Snek chat by Molodetz" in their
respective templates, improving user-facing page identification.
2025-03-08 17:04:53 +00:00
retoor e3eaa4437e feat: convert search user results from plain list to rich thread cards with user metadata
Refactor search_user.html template to replace simple `<ul><li>` list with detailed thread-style cards showing avatar, nick, color, and last ping timestamp. Update search_user.py view to pass `current_user` context and unwrap user records from service response.
2025-03-08 16:33:17 +00:00
BordedDev 4b59f8b780 fix: correct border-radius on follow-up messages to prevent sharp corners 2025-03-08 16:25:56 +00:00
retoor f2cda2142c fix: correct dm channel name resolution and add channel member access check
- Fix ChannelMemberModel.get_name() to check channel tag instead of uid for dm detection
- Add fallback to channel member label when channel name is empty
- Handle self-dm edge case in ChannelMemberService.get_dm() with null join
- Update threads template to use channel name and color instead of last message user data
- Add channel membership verification in WebView to return 404 for non-members
- Pass resolved channel member name to web template instead of raw channel label
2025-03-08 07:25:49 +00:00
retoor 80f7e172d6 feat: add thread list view with shared CSS classes for message and thread styling 2025-03-08 06:22:14 +00:00
retoor e300591464 fix: remove chat header and fix opacity styling on thread list items
Remove the empty chat header div with placeholder question mark heading, and add explicit opacity: 1 to both the author name and timestamp elements in thread message previews to ensure consistent visibility across all thread entries.
2025-03-08 03:24:39 +00:00
retoor 9a1527d2e7 fix: replace display block with opacity 1 for avatar visibility in thread messages 2025-03-08 03:11:34 +00:00
retoor 3c772b53b2 fix: add display block override to avatar div in thread message template 2025-03-08 03:10:05 +00:00
retoor 6f9eddf443 fix: remove avatar class from thread message div to fix missing styling
The diff removes the `class="avatar"` attribute from the `<div>` element wrapping the avatar image in the thread message template, while keeping the inline `background-color` and `color` styles. This change likely addresses a CSS specificity issue or removes an unintended class that was overriding or conflicting with other avatar styling rules in the thread list view.
2025-03-08 03:07:21 +00:00
retoor 03bfa8a18e fix: resolve socket connection promise for already connected state
The fix ensures that when `connect()` is called on an already connected socket, the promise resolves immediately with the socket instance rather than being deferred. Previously, the code only resolved immediately when not connecting, leaving connected sockets waiting indefinitely. The new logic checks `isConnected` first, then falls through to `isConnecting` before adding to the promise queue.
2025-03-08 02:51:54 +00:00
retoor b95510c4ed feat: add ThreadsView route and model methods for channel members and last message retrieval 2025-03-07 19:59:11 +00:00
retoor 3ea6a73dee feat: add threads list view with template and async data fetching
Implement ThreadsView handler that retrieves user channel memberships, fetches last message details (text, user nick, color) for each thread, and renders the threads.html template. The template displays a chat-style thread list with avatar, author, message preview, and relative timestamps using client-side time formatting.
2025-03-07 19:58:53 +00:00
retoor aad5e3dfd9 feat: remove ban/mute/deleted filters from notification channel member query
The notification service's channel member query for sending notifications
now includes all members regardless of ban, mute, or deletion status,
allowing notifications to be delivered to previously filtered-out members.
2025-03-05 17:51:50 +00:00
retoor 76d7f139e8 feat: add debug print of username when inserting notification for channel member
When incrementing new_count for a channel member during notification creation, log the associated username to stdout for debugging purposes.
2025-03-05 17:48:27 +00:00
retoor 0c80ba46a9 fix: remove redundant get call in BaseService list method to return raw mapper models 2025-03-05 17:40:53 +00:00
retoor 7cf3484320 fix: replace yield of raw model with hydrated object in BaseService find method 2025-03-05 17:37:44 +00:00
retoor 40f4bbfcf8 fix: remove debug print from notification service and add debug print in mapper save 2025-03-05 17:24:32 +00:00
retoor 33a392e3db feat: add debug print on channel member notification count increment
Insert a debug print statement in the notification service's channel member update logic to log when a new notification count is incremented, aiding in development and troubleshooting of notification acceptance flow.
2025-03-05 17:18:48 +00:00
retoor 7c199e316d fix: remove hardcoded new_count field from notification creation model 2025-03-05 17:18:06 +00:00
retoor 51d00b704b feat: add new_count field to notification creation and fix trailing whitespace
- Set model['new_count'] to 1337 when creating a new notification in NotificationService.create
- Remove trailing whitespace on empty line in channel message notification handler
2025-03-05 17:17:43 +00:00
retoor 1acf8e03a5 feat: move new_count increment from channel_message to channel_member in notification creation
The new_count field update logic is relocated from the ChatService (where it was applied to the channel_message document) into the NotificationService, where it now increments the new_count on the channel_member document instead. This ensures unread message counts are tracked per-member rather than per-channel, fixing the notification acceptance flow.
2025-03-05 17:09:54 +00:00
retoor b42156ee68 feat: increment new_count on channel_message and move notification creation before broadcast
The diff shows two changes: in chat.py, new_count is now incremented on the channel_message document itself (with a fallback to 0 if missing) and saved before notification creation; in notification.py, the previous logic that incremented new_count on channel_member documents is removed, consolidating the count tracking onto the channel_message object.
2025-03-05 17:05:34 +00:00
retoor 4f1fd2e906 feat: add new_count field to subscription response in RPCView 2025-03-05 17:02:02 +00:00
retoor c7ee7bd5b3 feat: add new_count field to ChannelMemberModel and increment on notification creation 2025-03-05 16:59:59 +00:00
retoor e464d39a93 fix: return boolean from mark_as_read when notification not found 2025-03-05 16:52:18 +00:00
retoor da8db940d8 feat: add mark-as-read and unread stats to notification service with RPC and web integration 2025-03-05 16:51:25 +00:00
retoor 7e55ff6422 feat: add push notification subscription and permission request logic to service worker
Implement requestNotificationPermission and subscribeUser functions to handle browser notification permission requests and push subscription registration with the backend. Add a push event listener to display notifications from received push data. Comment out the existing install, activate, and notificationclick handlers to transition to the new push-based notification flow.
2025-03-05 16:19:24 +00:00
retoor efa03e7ee3 fix: strip trailing commas from href and use filename in upload link
- In `set_link_target_blank`, extend `.strip(".")` to also strip trailing commas from href attributes
- In `UploadView`, change upload response link text from static "[url]" to dynamic filename variable
2025-03-02 23:35:08 +00:00
retoor d6a0dff8fb fix: wrap optional index creation in try-except to suppress failures silently 2025-03-02 13:59:25 +00:00
retoor ed13f2aa7d feat: add last_message_on field to channel and update on message send
Add optional `last_message_on` string field to ChannelModel to track when the last message was sent. In ChatService.send(), validate channel existence before proceeding, set `last_message_on` to current timestamp after successful message delivery, and persist the updated channel record.
2025-03-02 13:49:38 +00:00
retoor 72abce75d2 feat: make avatar retrieval accessible without authentication by setting login_required to False 2025-02-28 14:42:52 +00:00
retoor ef59222912 feat: add promotional tagline and remove navigation links from landing page
- Inserted a poetic tagline beneath the "Snek" heading to convey the project's ethos of being lean and optimized versus bloated competitors.
- Capitalized "OR" separator for visual consistency between Login and Register buttons.
- Removed three navigation links (Design choices, App preview, API docs) to simplify the landing page and focus on core registration actions.
2025-02-26 05:21:48 +00:00
retoor db146ffb64 fix: enable unique avatar generation by passing True for triangulate parameter
The change modifies the call to `multiavatar.multiavatar()` in the avatar view handler, replacing the second argument from `None` to `True`. This enables triangulation for unique avatar identifiers, ensuring visually distinct avatars are generated instead of falling back to a default rendering path.
2025-02-25 23:46:48 +00:00
retoor cf78cf5052 feat: generate unique uuid for 'unique' uid in avatar get handler
When the uid parameter equals the literal string "unique", the handler now generates a random UUID v4 to produce a distinct avatar instead of returning the deterministic multiavatar for the string "unique". This allows clients to request a fresh random avatar by passing "unique" as the uid.
2025-02-25 23:40:18 +00:00
retoor 3639bfb086 feat: add multiavatar-based SVG avatar endpoint and integrate into message template
Add a new `/avatar/{uid}.svg` route served by `AvatarView` that generates deterministic SVG avatars using the `multiavatar` library keyed on user UID. Register the view in the application router and update `message.html` to replace the plain initial-letter avatar with an `<img>` tag pointing to the new endpoint. Also add `multiavatar` to project dependencies in `pyproject.toml`, introduce `python` and `run` convenience targets in the Makefile, and set aggressive `Cache-Control` headers on both the avatar and upload responses to reduce server load.
2025-02-25 23:22:36 +00:00
retoor d011dc2897 feat: add sidebar_channels template with ChannelSidebar class for message count tracking
Introduce a new sidebar_channels.html template that renders a channel list with unique IDs per channel. The accompanying ChannelSidebar JavaScript class provides methods to retrieve channel list items by UID, increment and display message counts, and update the DOM with the current count for each channel.
2025-02-22 00:21:57 +00:00
retoor 51bc07dddc fix: extract sidebar channels into partial template and fix DM channel uid reference 2025-02-22 00:21:44 +00:00
retoor 3b42f59528 feat: replace flat notification sound array with named sound map and add mention detection logic
Refactor NotificationAudio.sounds from a positional array to a named object mapping event types ("message", "mention", "messageOtherChannel", "ping") to distinct audio files. Add three new notification WAV assets for mention, other-channel, and ping sounds. Update the channel-message event handler in web.html to use named sound keys, and introduce extractMentions() and isMentionForSomeoneElse() helpers to differentiate messages mentioning the current user from those mentioning other users, playing the appropriate notification sound for each case.
2025-02-20 23:41:22 +00:00
retoor 547cbe1a23 feat: add mention detection and dedicated sound for user mentions in chat
Add a new `isMention` function that checks if a chat message contains the current user's username, and play the new `mention1.wav` sound (index 1) when a mention is detected instead of the default beep. Also remove redundant dataset attributes from the message element creation in the channel-message event handler.
2025-02-20 23:24:14 +00:00
retoor dfcb29935e fix: reduce gunicorn worker count from 4 to 1 in compose.yml entrypoint 2025-02-20 23:24:44 +00:00
retoor 7f91e7925b feat: add database indexes, update CSS container styles, and enhance RPC query security
- Add unique index on username column in user table and composite indexes on channel_member and channel_message tables during app initialization
- Introduce .container CSS class with flex layout, padding, and nested styling for links and lists in base.css
- Change PWA manifest scope from /web.html to / to cover entire app
- Remove commented-out YouTube embed code from template.py and add .jpg to supported image extensions
- Rename "Chat Rooms" heading to "Channels" in app.html template
- Restructure search_user.html template with proper indentation and container class for form and results
- Strip sensitive fields (email, password, message, html) from RPC query results before returning them
2025-02-20 04:56:31 +00:00
retoor 1aee914688 fix: move Application instantiation to module level and increase gunicorn workers to 4
Move the `Application` instance creation from inside the async `main()` function to module scope in `src/snek/app.py`, ensuring the app object is available at import time for gunicorn's worker initialization. In `compose.yml`, increase the gunicorn worker count from 1 to 4 to improve concurrent request handling under the aiohttp worker class.
2025-02-19 22:23:24 +00:00
retoor fa265352a3 fix: reduce gunicorn worker count from 4 to 1 in compose.yml entrypoint 2025-02-19 22:21:08 +00:00
retoor 19d07af0f9 fix: switch compose entrypoint to gunicorn with 4 workers and comment out dev python runner
The compose.yml service entrypoint is changed from the development python runner to
gunicorn with 4 workers using aiohttp worker class, binding on port 8081. The previous
python entrypoint is commented out for reference.
2025-02-19 22:20:10 +00:00
retoor 28dc33df88 feat: add ascii art snake emoji data entry to template module
Insert a large multi-line ASCII art string as a new key in the emoji.EMOJI_DATA dictionary within the snek template module, expanding the emoji mapping with a decorative snake illustration.
2025-02-19 21:32:15 +00:00
retoor cdc1bdbc30 fix: correct typo in profiler sort parameter variable name 2025-02-18 12:48:48 +00:00
retoor b6733836aa fix: add query parameter support for sort order in profiler handler 2025-02-18 12:47:34 +00:00
retoor f31209da6c feat: add cProfile-based profiler with middleware and handler for performance analysis
- Introduce new profiler module in snek/system/profiler.py with middleware, handler, and context manager
- Register /profiler.html view in Application router to display profiling stats
- Wrap RPC WebSocket message handling with Profiler context manager for per-request profiling
- Remove asyncio event loop debug mode from main startup
2025-02-18 12:29:33 +00:00
retoor cd67cda332 fix: remove quotes from env vars and add debug logging to app startup 2025-02-18 11:38:44 +00:00
retoor edafacf83a fix: switch compose entrypoint to python module and enable asyncio debug in app.py
- Comment out gunicorn entrypoint in compose.yml and use python -m snek.app instead
- Add asyncio import and create async main() function in app.py
- Enable asyncio event loop debug mode via loop.set_debug(True)
- Replace direct web.run_app call with asyncio.run(main()) for proper async context
2025-02-18 11:28:49 +00:00
retoor 81cc2a9645 chore: add 192x192 icon entry to manifest.json for progressive web app support 2025-02-17 21:53:20 +00:00
retoor f7bfdf4919 fix: remove display_override field from web app manifest
The `display_override` array with value `["browser"]` was removed from the
`src/snek/static/manifest.json` file to prevent the browser from overriding
the intended display mode, ensuring the PWA launches in its configured
standalone or fullscreen mode as expected.
2025-02-17 21:37:15 +00:00
retoor 24d2243afb feat: add manifest metadata fields and enable install prompt button in app template
- Extend manifest.json with id, orientation, scope, theme/background colors, related_applications, screenshots, dir, lang, launch_path, display_override, and short_name for full PWA compliance
- Move manifest link element to head section and add missing rel attribute
- Comment out push.js script tag to disable legacy push functionality
- Refactor beforeinstallprompt handler: remove preventDefault call, show install button immediately on event, and clean up nested DOMContentLoaded listener
2025-02-17 21:31:17 +00:00
retoor b301648f02 chore: strip verbose Jinja2 comment block with license and attribution from web.html template 2025-02-17 19:34:24 +00:00
retoor 7de2c1ec3e chore: add MIT license and author comment blocks to web.html template 2025-02-17 19:31:18 +00:00
retoor f808559f10 feat: enable infinite scroll by loading messages before the first visible one
Changed the pagination offset parameter from 1 to 0 in the getMessages RPC call, allowing the chat to load messages that precede the current first message rather than skipping it. This enables seamless infinite scroll behavior when the user scrolls up in the message history.
2025-02-17 19:20:32 +00:00
retoor 0a3eb5bd1e fix: move isLoadingExtra reset after message insertion in scroll handler 2025-02-17 19:17:17 +00:00
retoor f9b89ddf3b fix: invert scroll threshold and add layout update after loading extra messages 2025-02-17 19:00:35 +00:00
retoor 4c84530811 feat: enable infinite scroll by uncommenting scrolled-past-half guard in chat
The `isScrolledPastHalf()` check was previously commented out, causing the chat to always load extra messages regardless of scroll position. This change re-enables the guard and adjusts the threshold from 1/4 to 1/2 of the scrollable height, ensuring new messages are only fetched when the user has scrolled past the midpoint of the current content.
2025-02-17 18:57:48 +00:00
retoor 014a0b7a29 feat: remove scroll position guard to enable infinite loading on any scroll
The conditional check `isScrolledPastHalf()` was commented out, removing the requirement that the user must scroll past the halfway point before extra content loads. Now `isLoadingExtra` is set to `true` immediately when the scroll handler fires, allowing infinite scroll to trigger on any scroll event rather than only after passing the 50% threshold.
2025-02-17 18:49:29 +00:00
retoor 0d0566e48f feat: invert scroll detection logic to load extra messages when near top
The `isScrolledPastHalf` function previously always returned true, causing continuous loading. Now it returns true only when scroll position is within the top quarter of the container, and the `loadExtra` guard condition is negated to trigger loading only when scrolled near the top.
2025-02-17 18:48:03 +00:00
retoor 3db95e9ea2 feat: remove early scroll-lock threshold to enable infinite scroll in chat messages
The `shouldAutoScroll` function previously returned false when the user scrolled past the top 20% of the scrollable area, preventing automatic scroll-to-bottom behavior. By commenting out this conditional block, the function now always returns true, allowing the chat view to continuously auto-scroll to new messages regardless of the user's current scroll position. This enables an infinite-scroll-like behavior where the viewport follows incoming content without interruption.
2025-02-17 18:45:46 +00:00
retoor bec6c0e70f feat: reduce auto-scroll threshold from 80% to 20% of scrollable height
The auto-scroll behavior in the chat messages container now triggers at a much lower scroll position (20% from bottom instead of 80%), allowing users to stay near the bottom of the conversation without being forcibly scrolled down. This enables a more natural infinite scroll experience where new messages only auto-scroll when the user is very close to the bottom of the chat history.
2025-02-17 18:44:31 +00:00
retoor 951e3f6ce0 feat: invert scroll-lock threshold to keep view anchored near bottom
The condition for preventing auto-scroll on new messages was changed from
checking if the user is within the top fifth of the scrollable area to
checking if they are beyond the bottom 80% (i.e., scrolled past 83.3%).
This effectively inverts the logic so that auto-scroll is suppressed only
when the user has scrolled significantly upward, rather than when they are
near the top. The change ensures the chat view remains locked to the latest
message unless the user deliberately scrolls far up.
2025-02-17 18:42:45 +00:00
retoor 6f1223741d feat: adjust auto-scroll threshold from 1/4 to 1/5 of scrollable height
The scroll detection logic in the chat messages container now triggers auto-scrolling
when the user is within the top 20% (1/5) of the remaining scrollable area, instead of
the previous 25% (1/4) threshold. This reduces premature scroll jumps during message
streaming, allowing users to read older content with less interruption.
2025-02-17 18:36:57 +00:00
retoor 88f3630124 feat: reduce auto-scroll threshold from half to quarter of scrollable height
The change modifies the condition in `isNearBottom()` within `web.html` to trigger auto-scroll when the user is within the top quarter of the remaining scrollable area instead of the top half, making the scroll behavior more responsive to new messages appearing at the bottom.
2025-02-17 18:36:45 +00:00
retoor 7485f13cef feat: hide scrollbar in chat messages container for infinite scroll
The scrollbar is now hidden using `scrollbar-width: none` on the `.chat-messages` class, enabling a seamless infinite scroll experience without visible scrollbar interference.
2025-02-17 18:32:28 +00:00
retoor fc5172b9d9 feat: hide scrollbar and prevent text selection for infinite scroll UI
- Added `overflow: hidden` to `.chat-container` to prevent body scroll interference
- Added `user-select: none` to `.chat-header` and `.avatar` to improve UX during scroll
- Removed scrollbar visibility by setting `display: none` on `::-webkit-scrollbar` while preserving scroll functionality
2025-02-17 18:30:50 +00:00
retoor 5cda61989e fix: force scrollbar visibility in chat-messages container for infinite scroll support 2025-02-17 18:26:16 +00:00
retoor 491ab6b42a feat: hide scrollbar in chat-messages container for infinite scroll illusion
The scrollbar is visually removed across all major browsers (Firefox, IE/Edge, and WebKit) by setting `scrollbar-width: none`, `-ms-overflow-style: none`, and hiding the `::-webkit-scrollbar` pseudo-element. This enables a seamless infinite-scroll appearance while preserving actual scroll functionality.
2025-02-17 18:21:03 +00:00
retoor b64f41ddda fix: invert scroll detection condition to enable infinite loading on reaching half page 2025-02-17 18:11:14 +00:00
retoor 40f44c6aca feat: invert scroll detection condition to trigger infinite load only when scrolled past half 2025-02-17 18:09:42 +00:00
retoor 034076db65 feat: replace offsetMessage with firstMessage for correct scroll pagination anchor
The diff changes the pagination parameter from `offsetMessage.dataset.created_at` to `firstMessage.dataset.created_at` in the `getMessages` RPC call, ensuring the scroll-up infinite loading uses the correct timestamp anchor for fetching older messages.
2025-02-17 18:06:10 +00:00
retoor 48bad0d245 feat: implement infinite scroll for message history in chat view
Add scroll event listener to messages container that triggers loading of older messages when user scrolls to top. Fix indentation of isLoadingExtra flag to execute after all messages are inserted, ensuring correct state management during pagination.
2025-02-17 18:04:59 +00:00
retoor e4310345db feat: replace scroll-triggered message loading with infinite scroll past halfway point
Replace the previous approach that relied on detecting visibility of the 10th message element with a new mechanism that loads older messages when the user scrolls past the halfway point of the scrollable content. Introduce `isScrolledPastHalf()` helper to compute scroll position relative to total scrollable height, and add `isLoadingExtra` guard to prevent concurrent requests. Remove the now-unused `offsetMessage` element selection and `dataset.seen` tracking logic.
2025-02-17 18:03:40 +00:00
retoor 4bde91541f feat: change scroll load offset from first to tenth child element
The offset element for loading additional chat messages was changed from the first child to the tenth child of the messages container, altering the scroll trigger point for infinite loading behavior.
2025-02-17 17:56:35 +00:00
retoor 8ad111c9c1 feat: change scroll offset target from 15th to 1st child for infinite loading trigger
The offset selector in loadExtra() was updated from nth-child(15) to nth-child(1), causing the infinite scroll mechanism to trigger loading of additional messages when the first message becomes visible rather than waiting for the 15th. This effectively makes the scroll loading activate much earlier in the chat history.
2025-02-17 17:54:25 +00:00
retoor 37c9a5a77e fix: rename get_messages to getMessages in scroll infinite handler
The RPC method call in the infinite scroll logic was using the snake_case name `get_messages` instead of the camelCase `getMessages` expected by the backend, causing the scroll-up pagination to silently fail when loading older messages.
2025-02-17 17:52:30 +00:00
retoor c6755e7500 fix: correct pagination cursor to use offsetMessage timestamp instead of fourthMessage 2025-02-17 17:52:19 +00:00
retoor dde3df73d1 feat: implement infinite scroll by shifting offset to 15th message and removing debug logs
Change the scroll-triggered message loading logic from checking the 4th message to the 15th message as the offset anchor, and remove console.info debug statements for channelUid and loaded messages.
2025-02-17 17:50:20 +00:00
retoor d313dfc6f5 fix: correct infinite scroll logic and add client-side pagination for channel messages 2025-02-17 17:42:59 +00:00
retoor 85f4a051c4 feat: add timestamp-based pagination to channel messages and auto-focus textbox
Add optional `timestamp` parameter to `ChannelMessageService.offset()` method enabling cursor-based pagination alongside existing offset-based pagination. When timestamp is provided and page >= 0, query filters for messages created after the given timestamp; when page < 0, filters for messages created before the timestamp. Update `RPCView.get_messages()` to pass the new timestamp parameter through to the service. Also add `textBox.focus()` call in web template to automatically focus the message input field on page load.
2025-02-17 12:37:05 +00:00
retoor 0ee697be84 feat: conditionally scroll to last message only when it is visible
Add `isElementVisible` helper to check if an element is within the viewport using `getBoundingClientRect`. Modify `updateLayout` to accept a `doScrollDown` parameter that controls whether the last message is scrolled into view. In the message receive handler, compute `doScrollDownBecauseLastMessageIsVisible` by checking if the last message exists and is visible, then pass it to `updateLayout` to prevent automatic scrolling when the user has scrolled up.
2025-02-17 09:51:26 +00:00
retoor 3397b6bb82 fix: add div to max-width rule and extend switch-user media selectors
- Add `div` to the `.message-content` max-width rule to prevent overflow of block-level elements
- Expand `.message.switch-user .text` selector to include `iframe` and `video` alongside `img` for consistent border-radius and max-width styling across embedded media types
2025-02-17 03:51:21 +00:00
retoor b0745d9344 fix: extend max-width constraint to video and iframe inside message-content 2025-02-17 03:49:09 +00:00
retoor 210597f26f feat: extend drive.bin route to accept file extension and update upload response to include it
- Change route pattern from `/drive.bin/{uid}` to `/drive.bin/{uid}.{ext}` in app.py
- Modify UploadView to append file extension to the generated response link
- Remove conditional image markdown rendering in favor of generic url link with extension
2025-02-16 22:05:56 +00:00
retoor 35e9a2070c fix: comment out YouTube video embed logic in set_link_target_blank
The YouTube embed transformation code that replaced video links with iframe embeds has been commented out, effectively disabling the feature while preserving the original implementation for potential future re-enablement.
2025-02-16 21:53:28 +00:00
retoor acd963cab9 fix: remove extraneous whitespace before linkify_https call in LinkifyExtension._to_html 2025-02-16 21:49:37 +00:00
retoor 4dacc4c6f7 feat: add embed_youtube, embed_image, and embed_media functions to template module
Add three new functions to src/snek/system/template.py that parse HTML anchor elements and replace YouTube links with iframe embeds, image links with img tags, and media file links with video/audio controls, enhancing the video player with automatic content embedding.
2025-02-16 21:41:33 +00:00
retoor 533041fc43 feat: add YouTube link auto-embedding with iframe replacement in set_link_target_blank
Detect YouTube video URLs by checking for "https://www.you" prefix and "?v=" parameter, extract the video ID, and replace the anchor element with a responsive embedded iframe player including standard YouTube embed attributes and security parameters.
2025-02-16 21:23:11 +00:00
retoor 33bc2f3c32 fix: correct channel lookup key from subscription uid to channel_uid in RPCView
The channel retrieval in the subscription loop was using the wrong key ('uid' instead of 'channel_uid'), causing lookups to fail for users subscribed to channels where their subscription uid differs from the channel uid. This fix ensures the correct field is used when fetching channel details for each subscription.
2025-02-15 22:03:45 +00:00
retoor b38978a7ea feat: add channel tag to subscription response in RPCView.list_channels
Include the channel's tag field when building the subscription list response, fetching the full channel object to populate the new "tag" key alongside existing name, uid, and permission fields.
2025-02-15 22:01:43 +00:00
retoor 7c65768e8e feat: replace socket list with set and add per-user socket tracking for targeted messaging
- Change `self.sockets` from list to set to prevent duplicate socket entries
- Introduce `self.users` dict mapping user_uid to a set of their active sockets
- Add `send_to_user` method enabling direct message delivery to all sockets of a specific user
- Refactor `broadcast` to iterate over channel members from DB and use `send_to_user` instead of maintaining a separate subscription set
- Temporarily short-circuit `subscribe` method (returns immediately) as subscriptions are now derived from channel membership
- Update cache log to include LRU item count for better debugging visibility
- Rename `room` parameter to `channel_uid` in RPC `send_message` for consistency with service layer
2025-02-15 20:56:26 +00:00
retoor 73a5a82387 feat: remove debug print statements and refactor socket service with user-aware connection management 2025-02-15 20:07:02 +00:00
retoor c9eb6fb129 feat: add channel list with DM user names and fix message scroll behavior
- Add get_other_dm_user method to ChannelMemberService to retrieve the other participant in a DM channel
- Update WebView to display DM partner's nickname instead of channel label in sidebar
- Change sidebar links to use channel uid and name from the new channel list structure
- Fix message offset query to silently handle errors instead of crashing
- Replace scroll-to-bottom with scrollIntoView on last message for smoother UX
- Increase initial layout update delay from 200ms to 1000ms for better timing
2025-02-13 19:21:34 +00:00
retoor 06107c2c92 feat: add dynamic channel list rendering and get_for_user service method
Implement `ChannelService.get_for_user` to fetch non-banned, non-deleted channels for a given user by iterating through channel memberships. Replace hardcoded sidebar links in `app.html` with Jinja2 template loop rendering channels from the context, enabling dynamic display of user-accessible chat rooms.
2025-02-13 18:47:05 +00:00
retoor f26748ee79 feat: add echo rpc method that returns noresponse to skip json reply
The echo method sends the received object directly via websocket and returns the string "noresponse", which prevents the RPC handler from sending an additional JSON response. This allows clients to receive their echoed data without the standard callId/success wrapper.
2025-02-11 20:11:21 +00:00
retoor 0886bd13be feat: replace iframe embed with markdown link and image syntax in upload response 2025-02-11 18:34:47 +00:00
retoor e5bf556459 feat: add iframe embed support for non-image files in upload view
Add extension type mapping and conditional response generation in UploadView to return iframe embeds for video, audio, and document files instead of markdown image syntax. Introduce base_url property on BaseView to construct absolute embed URLs.
2025-02-11 18:30:55 +00:00
retoor dcd9614f45 feat: redirect to existing dm channel when user already has one
When a user attempts to create a private chat with another user, the system now checks for an existing direct message channel and redirects to it instead of proceeding to create a duplicate. This prevents duplicate DM channels and provides a seamless user experience by reusing the existing conversation.
2025-02-11 14:42:54 +00:00
retoor 4b6fd2c96e feat: redirect to existing DM channel if already created
When a user navigates to a private chat page with another user, the handler now checks if a direct message channel already exists between them. If found, it immediately redirects to that channel's page instead of proceeding to create a new one, preventing duplicate DM channel creation and improving navigation flow.
2025-02-11 14:42:03 +00:00
retoor c6d4e8959f feat: normalize dm channel tag to lowercase in channel creation
The tag parameter for direct message channels was changed from uppercase "DM" to lowercase "dm" in the ChannelService.create call, ensuring consistent tag formatting across the codebase for private chat channels.
2025-02-11 14:40:04 +00:00
retoor 5ce1211616 feat: remove tag parameter from create_dm channel member creation calls
The create_dm method in ChannelMemberService previously passed a "dm" tag to the create method for both the from_user and to_user memberships. This change removes the tag parameter from both create calls, simplifying the direct message channel member creation logic.
2025-02-11 14:38:05 +00:00
retoor fefd37de3e feat: add private direct messaging with automatic DM channel creation and lookup
Implement get_dm method in ChannelService that finds existing DM channels via channel_member join query or creates new ones with 'DM' tag. Add get method with fallback lookup by uid, name, and #name. Fix channel_member is_banned check to use dict access. Update web view to resolve channels by uid or user uid, and fix template references from channel_uid to uid.
2025-02-11 14:34:31 +00:00
retoor 7633cddf34 feat: invert online status threshold to show users active within last 20 seconds
Previously the channel service yielded users whose last ping was 20 or more seconds ago, effectively showing offline users. The condition is now flipped to `< 20` so only users with a recent ping (within the last 20 seconds) are returned as online.
2025-02-10 15:34:26 +00:00
retoor b7374c76a9 feat: add last_ping field to online and channel user dicts in RPC view 2025-02-10 15:32:30 +00:00
retoor 379ec63485 feat: reduce online status threshold from 30s to 20s in channel presence check
The change modifies the time delta comparison in the `_iter_online_users` generator within `ChannelService`, flipping the condition from `< 30` to `>= 20` seconds since last ping. This effectively lowers the threshold for considering a user online, making the presence detection more responsive and reducing the window for stale status display.
2025-02-10 15:24:43 +00:00
retoor 7ff6dbf3c7 feat: skip yielding None user objects in channel member iteration
The `get_users` generator in `ChannelService` previously yielded the result of `self.services.user.get()` unconditionally, which could return `None` for deleted or missing users. This change adds a guard to only yield when a valid user object is returned, preventing downstream consumers from receiving `None` entries. This is a prerequisite for the `get_online_users` method to correctly filter online status without encountering null references.
2025-02-10 15:22:34 +00:00
retoor 58cb9c7b39 feat: add online status tracking via last_ping field and ping RPC method
Introduce a `last_ping` string field on the UserModel to store the timestamp of the user's last activity. Implement `get_online_users` in ChannelService that filters channel members whose `last_ping` is within the last 30 seconds. Add a `ping` RPC endpoint that updates the user's `last_ping` to the current server time. On the client side, emit a "connected" event on WebSocket open, trigger an immediate "online" ping, and schedule a recurring "active" ping every 15 seconds to maintain the online status.
2025-02-10 15:18:08 +00:00
retoor ad60ff1aa2 feat: add Access-Control-Allow-Credentials header to all CORS middleware responses
Add the `Access-Control-Allow-Credentials: true` header to both the preflight
OPTIONS handler and the main request handler in `cors_allow_middleware` and
`cors_middleware` functions, enabling credentialed cross-origin requests.
2025-02-09 22:49:54 +00:00
retoor 3988195d57 fix: fix CSS selector nesting for .message .time display rule
The previous rule `.message:has(+ .message.switch-user), .message:last-child .time` incorrectly applied `display: block` to `.time` only when it was a direct child of `.message:last-child`, but failed to scope it properly for the `:has()` selector. The fix wraps both selectors in a parent rule `.message:has(+ .message.switch-user), .message:last-child` and nests `.time { display: block; }` inside, ensuring consistent behavior across both cases.
2025-02-09 15:14:00 +00:00
retoor 54fb92466a style: remove redundant display block from message-content css class 2025-02-09 14:59:26 +00:00
retoor 1367c1743a fix: constrain message-content width to prevent overflow in chat layout 2025-02-09 14:56:19 +00:00
retoor 3b9736a027 feat: append firstChild of message element instead of full element to chat container
The change modifies the chat message insertion logic in web.html to append
`message.firstChild` rather than the entire `message` element, ensuring only
the inner content is added to the chat-messages container. This prevents
wrapping the HTML fragment in an extra div layer, preserving the expected
DOM structure for subsequent layout updates.
2025-02-09 14:53:45 +00:00
retoor 6577c6afcb style: add global word-break and hyphens to prevent overflow in chat messages 2025-02-09 14:46:02 +00:00
retoor 316264cab4 fix: append full message element instead of its first child in chat
The change replaces `message.firstChild` with `message` when appending to `.chat-messages`, ensuring the complete message wrapper (including outer div with data attributes) is added to the DOM rather than just its inner content. This preserves the element's dataset properties for subsequent interactions.
2025-02-09 13:58:26 +00:00
retoor be8574583b style: clean up base.css by removing commented padding, consolidating tile rules, and fixing main layout 2025-02-09 12:19:54 +00:00
retoor bdf04e3023 fix: add delayed layout recalculation after chat message insertion
The commit adds a 200ms setTimeout call to trigger updateLayout() a second time after a new chat message is appended to the DOM. This addresses a layout timing issue where the initial synchronous updateLayout() call may execute before the browser has fully rendered the newly inserted message element, causing incorrect scroll or sizing calculations. The delayed call ensures layout metrics are recalculated after the browser's rendering pipeline has completed for the new content.
2025-02-09 12:07:38 +00:00
retoor 62c63c8128 style: reformat chat template indentation and remove unused message metadata divs 2025-02-09 12:06:12 +00:00
retoor 71cc792b43 feat: add custom scrollbar styling for chat-messages with thin width and muted colors
Add WebKit scrollbar customization rules (::-webkit-scrollbar, ::-webkit-scrollbar-track, ::-webkit-scrollbar-thumb) and Firefox scrollbar properties (scrollbar-width: thin, scrollbar-color) to the .chat-messages element, using a 6px width, #888 thumb with 3px border-radius, and #f1f1f1 track background for a minimal aesthetic.
2025-02-09 11:44:51 +00:00
retoor 0272c2ab85 feat: add user color to message context and implement offset-based message pagination with extended dict
Add `color` field to message template context in ChannelMessageService. Implement `to_extended_dict` method to return full message data including user color and username. Add `offset` method for paginated message queries with descending order by creation time. Fix WebSocket event emission in app.js to use correct event name. Add `timeDescription` and `timeAgo` utility functions for client-side timestamp formatting. Enable scrollable chat messages container with overflow-y auto. Add CSS rules for message content block display and responsive image sizing. Fix scrollIntoView behavior to align to block end. Correct service worker registration path. Fix upload button to use local channelUid attribute. Strip trailing dots from link hrefs and exclude trailing dots from URL regex. Remove redundant script imports from app.html template. Uncomment message HTML template structure with proper time element. Replace chat-window element with server-rendered message list in web.html template. Refactor RPC get_messages to use new offset-based query with extended message dict.
2025-02-09 11:35:38 +00:00
retoor dd3f1deeb0 fix: add WebView route and fix login form validation call
- Add `/channel/{channel}.html` route to router in `app.py`
- Fix `LoginView.submit` to call `form.is_valid()` as a method instead of property
- Implement `WebView.get` with channel lookup, member check, and redirect logic
2025-02-09 06:54:46 +00:00
retoor a10c85bcd8 chore: add MIT license headers and author attribution to all view modules
Add full MIT license text and author credit (`retoor@molodetz.nl`) as
file-level comments across all view files (`about.py`, `docs.py`,
`index.py`, `login.py`, `login_form.py`, `logout.py`, `register.py`,
`register_form.py`). Also fix `form.is_valid` to `form.is_valid()` in
login views and consolidate session assignment into a single `update()`
call in `login.py`.
2025-02-09 01:25:06 +00:00
retoor 4dee95e7bc fix: add truncate keyword to sql injection blacklist in rpc query handler 2025-02-09 01:07:29 +00:00
retoor d4f125b69b feat: add search user template with form and results listing
Implement a new search_user.html template extending the app layout, featuring a GET form with a required username input and a submit button, plus an unordered list displaying matched users as clickable links to their profile pages.
2025-02-09 00:42:50 +00:00
retoor 7a7a65fb74 feat: remove deprecated search-user.html template and update view to use search_user.html
The old `search-user.html` template is deleted entirely, and the `SearchUserView` now renders `search_user.html` instead, aligning the template reference with the new naming convention.
2025-02-09 00:42:34 +00:00
retoor 11afd5ea40 fix: add no-select class to nav element in app.html template 2025-02-09 00:38:01 +00:00
retoor cb3c1b3497 feat: add no-select class to chat title and logo elements to prevent text selection 2025-02-09 00:34:02 +00:00
retoor 23e2a4fbe5 feat: add no-select CSS class and apply it to UI elements to prevent text selection
Add a reusable `.no-select` CSS class with cross-browser user-select: none properties
and apply it to navigation links, sidebar headings, chat room links, and avatar
elements in the message list to improve UI consistency and prevent accidental
text selection during interaction.
2025-02-09 00:31:34 +00:00
retoor f7592bcbf5 fix: enable WAL journal mode and disable syncnorm in app startup
- Add PRAGMA journal_mode=WAL query to improve concurrent read performance
- Add PRAGMA syncnorm=off query to reduce disk synchronization overhead
- Both pragmas executed immediately after router setup in Application.__init__
2025-02-08 22:48:42 +00:00
retoor 3c5edc1463 style: remove commented-out CSS rules and extraneous whitespace in base.css chat-messages section 2025-02-08 21:03:26 +00:00
retoor ad8cc9d11a feat: add user search page with styled form, result list, and fancy-button submit
- Replace generic-form with custom form containing text input and fancy-button
- Add CSS for anchor links, chat-area list items, and text input styling
- Update search_user view to handle query parameter and render user results
- Register fancy-button and generic-form scripts in app.html template
- Modify fancy-button to support form submission via 'submit' url attribute
2025-02-08 20:54:46 +00:00
retoor 297a4fccd0 fix: extend SQL query blocklist to include replace, insert and require select keyword in RPCView 2025-02-08 16:59:38 +00:00
retoor 566558db4a feat: add SearchUserForm class with username input and search button
Implement a new form component for searching users, including a required username input field with length constraints (1-128 characters) and a submit button labeled "Search". The form is structured under the `snek.form.search_user` module and extends the base `Form` class with a title element.
2025-02-08 16:33:02 +00:00
retoor 74247d562c feat: add search user page with push notification service worker support
Add SearchUserView with HTML and JSON endpoints, create search-user.html template extending app.html with navigation link, implement push.js for service worker registration and push subscription handling, add service-worker.js for push event display and notification click handling, and fix install prompt event listener registration in app.html
2025-02-08 16:31:03 +00:00
retoor a4d56deb11 fix: relax username and password validation regex across login, register, and user model
- Allow hyphens in username regex in LoginForm and UserModel
- Add plus and forward slash to allowed username characters in UserModel
- Replace password minimum length regex with min_length=1 constraint in all three files
2025-02-08 16:14:37 +00:00
retoor 87f21b6a49 feat: add app.html template with chat UI layout and PWA install prompt
Create the main application template for the Snek chat interface, including header navigation with home, install, users, settings, and logout links, a sidebar listing chat rooms (General, Development, Support, Random), and a chat-window component in the main content area. Include script references for push, upload-button, html-frame, schedule, app, models, message-list, message-list-manager, chat-input, and chat-window modules, along with base.css stylesheet and manifest.json for PWA support. Add a beforeinstallprompt event listener that stores the install prompt event and displays an alert for triggering the PWA installation flow.
2025-02-08 16:09:05 +00:00
retoor 19e6890204 fix: allow hyphens in username regex and add user search service with RPC endpoint 2025-02-08 16:07:04 +00:00
retoor 6e880ff31a fix: close stale WebSocket before reconnecting in Socket class 2025-02-07 18:45:05 +00:00
retoor 5824b359dd feat: replace navigation link text with emoji icons in web.html header
Updated the nav links in src/snek/templates/web.html to use emoji characters
instead of plain text labels: Home→🏠, Install→📥, added 👥 link, Settings→⚙️,
Logout→🔒.
2025-02-07 18:42:03 +00:00
retoor 50f377f24b feat: add get_lexer method with fallback to default language in MarkdownRenderer
Introduce a new `get_lexer` method that attempts to retrieve a Pygments lexer by the given language name, falling back to 'bash' if the language is unknown. Refactor `block_code` to use this method, removing the direct `get_lexer_by_name` call and the fallback HTML escape block for missing lexer results.
2025-02-07 17:21:41 +00:00
retoor d1d55af4b7 feat: replace deprecated html.parser.escape with html.escape in markdown renderer 2025-02-07 17:09:15 +00:00
retoor a0a49b4e5a fix: escape HTML entities in code block fallback for MarkdownRenderer 2025-02-07 17:06:48 +00:00
retoor ca05e228e6 fix: guard empty highlight result in MarkdownRenderer block_code method 2025-02-07 17:02:10 +00:00
retoor 551862573b fix: default to bash lexer and add fallback for missing lexer in markdown code blocks 2025-02-07 16:49:29 +00:00
retoor 03006f2e30 fix: propagate RPC method failure to success field in JSON response 2025-02-06 16:31:14 +00:00
retoor 6299e0dc17 feat: add snek1 emoji gif asset and register it in emoji data
- Add binary gif file src/snek/static/emoji/snek1.gif
- Register :snek1: emoji mapping in template.py with alias and status
- Swap pywebpush dependency for PyJWT in pyproject.toml
- Reorder emoji, linkify, markdown processing order in message.html template
2025-02-05 18:11:11 +00:00
retoor 92b6291dc4 feat: add drive service with upload endpoint, mappers, models, and frontend upload integration 2025-02-04 22:38:13 +00:00
retoor 4b69d7f078 chore: remove setup.cfg, add aiofiles dep, and fix this context in Socket connect 2025-02-03 20:15:18 +00:00
retoor 5b67bc765b fix: remove pre-wrap whitespace from message text and code blocks 2025-02-03 19:46:41 +00:00
retoor 84d3b0c5a1 fix: correct upload button selector and restore disabled state logic in chat-input
Fix the non-working upload button by updating the CSS selector from `.chat-input button` to `.chat-input upload-button` in base.css, and restore the button disabled state logic that was lost during the refactor in chat-input.js. Also add missing semicolons and fix the debug log reference to undefined `params` variable in RESTClient.post.
2025-02-03 19:45:29 +00:00
retoor 544971672e fix: restore avatar dimensions in message-list for narrow viewports
Remove the zero-height, zero-padding, and zero-margin rules from the .avatar class within the narrow viewport media query, reverting the avatar to its default visible sizing.
2025-02-03 00:30:43 +00:00
retoor e6e731f9d9 feat: replace display:none with zero dimensions for avatar in narrow message-list
The change modifies the `.avatar` class inside a narrow `message-list` media query, swapping `display: none` for explicit zero `height`, `padding`, and `margin` to preserve layout flow while visually hiding the element.
2025-02-03 00:28:48 +00:00
retoor 13d0dd6a71 fix: uncomment padding on message-list and hide avatar on narrow screens 2025-02-03 00:27:21 +00:00
retoor c16f8e4e8d style: remove padding from message-list items in base.css 2025-02-03 00:24:09 +00:00
retoor 2e863c34cd style: move text wrapping properties from global body to .text class in base.css 2025-02-02 23:10:09 +00:00
retoor 8c8b36cb59 chore: add pre-wrap and break-word to body for long text wrapping in base.css 2025-02-02 23:08:17 +00:00
retoor edf9c31506 style: add white-space pre-wrap and word-break rules to highlight.css pre block 2025-02-02 23:07:33 +00:00
retoor 3cb9dbfb89 fix: prevent notification for own messages by deferring user assignment
The commit removes the `rooms` array and its initialization from the `App` constructor, and changes the `user` property assignment from an `await` call to a promise-based `.then()` callback. This ensures that `me.user` is set asynchronously after the constructor completes, preventing the notification system from triggering alerts for messages sent by the current user before their identity is fully loaded.
2025-02-02 22:21:43 +00:00
retoor cc8dc01f58 fix: suppress audio notification for own messages in chat window
Add pywebpush dependency to pyproject.toml for future push notification support.
Remove unused Message, Messages, Room, InlineAppElement, and Page classes from app.js.
Add user property to ChatWindowElement and fetch current user in App constructor.
Modify message event handler to only play notification sound when message user_uid differs from current user.
2025-02-02 22:14:00 +00:00
retoor 28c71e254e fix: fix typo in highlight.css link rel attribute and restructure comment wrapping in message template 2025-02-01 18:41:38 +00:00
retoor 5c039c1cdd feat: add syntax highlighting CSS stylesheet for code blocks in static assets
Add a new highlight.css file under src/snek/static/ providing Pygments-compatible CSS classes for syntax highlighting. The stylesheet defines color and font-style rules for over 40 token types including comments, keywords, operators, strings, numbers, and generic markup elements, with a default monospace pre block and line-number styling for linenos spans.
2025-02-01 18:38:35 +00:00
retoor bcd7c7eb94 feat: add highlight.css stylesheet link to message template
Insert a <link> tag referencing /highlight.css before the message rendering block in message.html to enable syntax highlighting for code blocks within chat messages.
2025-02-01 18:35:27 +00:00
retoor efc41c73f3 fix: remove automatic Z suffix from isoDate in MessageListElement timeDescription 2025-02-01 16:39:57 +00:00
retoor 3fb06d275d feat: add emoji dependency and EmojiExtension for rendering emoji aliases in templates
- Add 'emoji' to project dependencies in pyproject.toml
- Create EmojiExtension class in template.py that parses {% emoji %}...{% endemoji %} blocks and converts emoji aliases (e.g., 😄) to Unicode emoji using emoji.emojize with language='alias'
- Register EmojiExtension in Application's Jinja2 environment
- Wrap message content with {% emoji %} tags in message.html template to enable emoji rendering in chat messages
2025-02-01 04:41:03 +00:00
retoor 1c972649ed fix: extend username regex to allow hyphens, plus signs, and forward slashes
The regex pattern for the username field in UserModel was updated from
`^[a-zA-Z0-9_]+$` to `^[a-zA-Z0-9_-+/]+$`, adding support for hyphens,
plus signs, and forward slashes in usernames.
2025-01-31 23:01:23 +00:00
retoor ae7f9716a5 fix: wrap linkify and markdown blocks inside autoescape false in message template 2025-01-31 21:07:01 +00:00
retoor 23de29bb53 fix: add missing async keyword to list comprehension in RPCView query method 2025-01-31 18:47:03 +00:00
retoor 43d20bb6a0 fix: add SQL injection guard to RPCView query method
Added a new `query` method to `RPCView` that requires login and validates
incoming queries by rejecting any containing dangerous SQL keywords
(drop, alter, update, delete) before executing the query against the
channel service and returning results as a list of dictionaries.
2025-01-31 18:46:02 +00:00
retoor 8bc5dc9ed3 fix: prevent empty message submission in ChatInputElement submit handler 2025-01-31 18:36:59 +00:00
retoor 6fce691201 fix: prevent empty message submission by trimming input in chat input handler 2025-01-31 18:33:18 +00:00
retoor c3a6efc99e fix: prevent shift+enter from submitting and clear input on enter in chat input
Changed keyup listener to keydown for the textBox in ChatInputElement. Now checks for shiftKey modifier: if shift is held, Enter inserts a line break without submitting; otherwise, it prevents default, dispatches submit event, and clears the input value. This fixes accidental submission when composing multi-line messages.
2025-01-31 18:24:50 +00:00
retoor d74b765569 fix: add preventDefault calls to change and keyup handlers in ChatInputElement 2025-01-31 18:17:40 +00:00
retoor 7910820586 fix: remove commented-out template wrapper and collapse markdown rendering into single line 2025-01-31 12:47:56 +00:00
retoor da859ca777 feat: inject highlight_styles inline style block into web.html head section 2025-01-31 12:45:41 +00:00
retoor 37d28ca1d8 fix: comment out highlight_styles style block in message.html template 2025-01-31 12:43:01 +00:00
retoor 36275f222f fix: re-enable socket subscription logic for authenticated users in RPCView
Uncomment and activate the previously disabled WebSocket subscription flow in the `RPCView` handler. When a user's session indicates they are logged in, the code now adds the WebSocket connection to the socket service, queries the `channel_member` service for non-deleted, non-banned memberships, and subscribes the socket to each associated channel UID. This restores real-time channel subscription behavior for authenticated sessions.
2025-01-31 12:42:12 +00:00
retoor 2c30a754f1 fix: suppress socket deletion on websocket error and close events
Temporarily comment out calls to `self.services.socket.delete(ws)` in both the
`WSMsgType.ERROR` and `WSMsgType.CLOSE` branches of the WebSocket handler in
`src/snek/view/rpc.py`, replacing them with `pass` to prevent premature
connection cleanup during unbuffered message processing.
2025-01-31 12:39:48 +00:00
retoor 23d28125b0 fix: add flush=True to websocket message print in RPCView
The print statement in the websocket message loop within RPCView's
subscription handler was missing flush=True, causing output buffering
and delayed log visibility during long-running websocket connections.
Adding flush=True ensures immediate stdout output for real-time
debugging and monitoring of incoming websocket messages.
2025-01-31 11:57:12 +00:00
retoor 5886eb226e fix: add json import to rpc view module for serialization support 2025-01-31 11:52:56 +00:00
retoor efcf5cc3c8 fix: replace deprecated send_text with send_str in RPCView._send_json
The `send_text` method on the websocket connection was deprecated and has been replaced with `send_str` to ensure compatibility with the current websocket library API. This change affects the JSON serialization and transmission path for all RPC responses sent through the `_send_json` helper method in the `RPCView` class.
2025-01-31 11:52:22 +00:00
retoor f3a5907c17 fix: correct indentation of _send_json and call_ping methods in RPCView
The _send_json and call_ping methods were incorrectly indented at the class level instead of being nested inside the try-except block of the parent method. This fix moves them to the proper indentation level within the class body, restoring correct method scope and preventing potential AttributeError or unexpected behavior when these methods are called.
2025-01-31 11:51:47 +00:00
retoor 8cc207d592 fix: replace direct ws.send_json calls with _send_json and add error handling in RPCView
The diff replaces all direct `self.ws.send_json()` calls with `self._send_json()` which serializes JSON using `json.dumps` with `default=str` for non-serializable objects. Additionally, the `rpc()` call in the message handler is wrapped in a try-except block that logs exceptions and deletes the websocket connection on failure. A new `_send_json` async method is introduced to centralize JSON sending logic.
2025-01-31 11:49:47 +00:00
retoor 389304abbb fix: flush exception result to stdout in RPCView error handler
Add `print(result, flush=True)` after constructing the exception result dictionary in the RPCView method execution error handler, ensuring the error details are immediately written to stdout without buffering.
2025-01-31 11:45:42 +00:00
retoor 767cf6bf61 fix: fix bare except clause and ensure socket cleanup on WS close
- Replace bare `except:` with `except IndexError:` in SocketService.delete
- Move socket.delete call inside ERROR handler to avoid premature cleanup
- Add explicit CLOSE handler to delete socket on clean WebSocket close
2025-01-31 11:43:38 +00:00
retoor 9788def09a fix: comment out debug print of subscription label in RPCView
The debug print statement that logged the subscription label during WebSocket
connection setup has been commented out to reduce noise in server logs during
normal operation.
2025-01-31 11:38:58 +00:00
retoor 5df420ba8b fix: remove session-based subscription and fix error response structure in RPCView
The diff shows two changes in `src/snek/view/rpc.py`:
1. In the error handling block, the `callId` and `success` fields are removed from the exception result dict, changing from `{"callId":call_id,"success": False, "data":{"exception":str(ex),"traceback":traceback.format_exc()}}` to `{"exception":str(ex),"traceback":traceback.format_exc()}`.
2. The entire session-based subscription logic (checking `logged_in`, adding websocket, subscribing to channels) is commented out, leaving only the `print` statement active.
2025-01-31 11:38:21 +00:00
retoor 1fe4083558 fix: add debug print of raw WebSocket message in RPCView message loop 2025-01-31 11:34:06 +00:00
retoor 176929cc30 fix: add flush=True to print calls in socket error handling and cleanup
Ensure debug output for WebSocket exceptions and socket deletion is immediately visible by enabling unbuffered stdout. Also fix missing trailing newline at end of file in the pass statement block.
2025-01-31 11:31:22 +00:00
retoor 92b46e9784 feat: add debug print of response messages in RPCView send_message
Add a flush-enabled print statement to output the messages list after
appending the formatted message in the send_message method of RPCView.
This aids in debugging by exposing the accumulated response messages
directly to stdout during execution.
2025-01-31 11:21:53 +00:00
retoor d54cd53623 feat: add PYTHONUNBUFFERED env var to both compose services for unbuffered output
Set PYTHONUNBUFFERED="1" in environment for both snek and snecssh services in compose.yml to disable Python output buffering, ensuring real-time log visibility from gunicorn and snekssh processes.
2025-01-31 11:17:10 +00:00
retoor 6371bb1fa5 feat: add initial Dockerfile, SSH server apps, and media upload web component
Add DockerfileDrive for containerized deployment with Python 3.12.8 Alpine base, including SSH host key and pip setup. Implement multiple SSH server variants: app.py with SFTP file transfer support on port 2225, app2.py with custom bash shell spawning, app3.py with terminal resize handling, app4.py with bcrypt password authentication, and app5.py with chat client infrastructure. Include TileGridElement and UploadButton custom web components in media-upload.js for gallery-style image display with hover effects.
2025-01-31 11:06:42 +00:00
retoor e290a9f5cb feat: add asyncssh dependency, snecssh service, gallery CSS, and traceback in RPC error handling
Add snecssh service to compose.yml with asyncssh dependency in pyproject.toml. Introduce gallery and tile CSS classes in base.css for image grid layout. Include commented-out tile-grid and upload-button elements in chat-window.js. Load media-upload.js script in web.html template. Wrap RPC method execution in try-except to return structured error response with exception message and formatted traceback.
2025-01-31 11:06:23 +00:00
retoor 2cdc9a7c00 chore: remove redundant inline comment from timeAgo diffMs calculation in message-list.js 2025-01-29 22:20:35 +00:00
retoor a885833383 feat: add username field to chat message and RPC response payloads
- Include `username` from user data when constructing `ChannelMessage` in `ChatService`
- Append `username` to message dict in `RPCView` response alongside existing `user_nick` field
2025-01-29 21:15:25 +00:00
retoor fbf39cdc5f fix: change install prompt button display from block to inline-block 2025-01-29 18:47:38 +00:00
retoor cde18f8181 style: comment out universal padding reset in base.css 2025-01-29 18:17:01 +00:00
retoor 55ebb53727 fix: guard linkify_https against missing https substring to avoid regex overhead
Add early return in linkify_https when the input text does not contain
"https://", preventing unnecessary BeautifulSoup parsing and regex
matching for non-link content.
2025-01-29 18:13:41 +00:00
retoor 418474e4fd fix: correct singular/plural grammar in timeAgo for day and hour labels 2025-01-29 17:58:38 +00:00
retoor 18ab7add4e feat: add timeAgo and timeDescription helpers and style inline images in messages
Add timeAgo() and timeDescription() methods to MessageListElement for displaying relative timestamps (e.g. "2 hours ago") alongside formatted time. Store raw ISO date in dataset for future use. Update base.css to constrain inline images within .message .text to 90% width with rounded corners, and apply same styling to .message.switch-user .text images.
2025-01-29 17:56:28 +00:00
retoor d5935fcc4d feat: add requests dependency, linkify/py3 jinja2 extensions, and message word-wrap css
Add `requests` to project dependencies in pyproject.toml. Register `LinkifyExtension` and `PythonExtension` from new `snek/system/template.py` module in the application's jinja2 environment. Apply `max-width: 100%`, `word-wrap`, `overflow-wrap`, and `hyphens` to `.message` class in base.css. Wrap message template content in `{% linkify %}...{% endlinkify %}` tags to auto-convert bare URLs to clickable links with `target="_blank"`, `rel="noopener noreferrer"`, and `referrerpolicy="no-referrer"`.
2025-01-29 17:12:22 +00:00
retoor bb6912fdb8 chore: remove cached bytecode file app.cpython-312.pyc from docs pycache 2025-01-29 16:19:49 +00:00
retoor 66ba529ef8 fix: add raw tag to preserve trailing whitespace in markdown message rendering 2025-01-29 16:17:27 +00:00
retoor 3f85f5fa5b fix: add trailing whitespace to message content in markdown template 2025-01-29 16:15:54 +00:00
retoor 8cedbe1d83 feat: add PYTHONDONTWRITEBYTECODE env var to compose service to prevent .pyc generation 2025-01-29 16:12:25 +00:00
retoor a06ebc455e feat: extract notification audio into separate class and fix schedule timeout property name
Extract the `playSound` method and related sound assets from the `App` class into a new `NotificationAudio` class with configurable timeout scheduling. Fix the `Schedule.cancelDelay` method to correctly reference `this.timeOut` instead of the incorrectly named `this.interval` property. Remove the `linkifyText` call from `MessageListElement` to render raw HTML content directly.
2025-01-29 16:08:40 +00:00
retoor 11a931f498 feat: add snek1.png image asset to static image directory 2025-01-29 15:47:25 +00:00
retoor 1fd609f867 fix: correct misplaced comment markers in message.html template
The diff shows a single-line change in `src/snek/templates/message.html` where a closing comment marker `{#}` was moved from the line after the `{% endmarkdown %}` block to the line before the `</div><div class="time">` element. This fixes a template rendering issue where the comment was incorrectly positioned, likely causing the time div to be commented out or the markdown block to be improperly closed. The change ensures the comment wraps only the intended content and the template structure remains valid.
2025-01-29 15:47:20 +00:00
retoor 5907e8a3f8 chore: add cache directory to gitignore and remove png exclusion pattern 2025-01-29 15:42:12 +00:00
retoor ddbfe6f65e feat: add documentation application with markdown rendering and static doc pages
Add a new `snek.docs` application module that serves documentation from a configurable path. The application extends the base `Application` class, integrates a Markdown extension for Jinja2 templates, and routes all requests under `/{tail:.*}` to a document handler that resolves file paths, returns 404 for missing resources, and renders templates. The commit also includes initial documentation pages: an API reference, a base layout with dark theme styling, a form API JavaScript guide, a form API Python example with custom validation, and an index page introducing the Snek chat application.
2025-01-29 15:41:34 +00:00
retoor caf25a20b0 chore: revert to previous directory structure by moving files back 2025-01-29 15:37:16 +00:00
retoor 970f0f63b7 chore: remove entire src/snek/docs directory with app, templates, and static assets
This commit deletes the documentation subsystem including the aiohttp-based Application class, all Jinja2/Markdown templates (base.html, index.html, api.html, form_api_python.html, form_api_javascript.html), and associated CSS styling. The removal eliminates the MarkdownExtension integration, the catch-all route handler for document serving, and all rendered documentation pages from the repository.
2025-01-29 15:36:18 +00:00
retoor b535815a8e feat: add message.html template with markdown rendering and metadata comments 2025-01-29 15:31:06 +00:00
retoor 93d0beb4a2 feat: add server-side markdown rendering via jinja2 and broadcast html field to clients
Add optional `html` field to ChannelMessageModel for storing rendered markdown. Extend ChannelMessageService.create() to render message content using Jinja2 template with user context, broadcasting the html alongside plain text. Update frontend MessageModel and MessageListElement to accept and display the html field when present, falling back to linkified plain text.
2025-01-29 15:30:54 +00:00
retoor 359d73c073 feat: hide install button by default with inline display none style
The install button anchor element now has a style="display:none" attribute
applied directly in the HTML template, preventing it from being visible
on initial page load until explicitly shown by JavaScript logic.
2025-01-29 05:45:26 +00:00
retoor 0f38ba432b feat: replace Rooms nav link with Install button and remove hidden fancy-button element
- Changed the navigation anchor from "Rooms" to "Install" with id="install-button"
- Removed the hidden `<fancy-button>` element from the sidebar, consolidating the install UI into the nav bar
2025-01-29 05:45:14 +00:00
retoor 334f457bea feat: add install button and login RPC with error logging in websocket handler 2025-01-29 05:43:42 +00:00
retoor f492911c85 fix: correct variable reference in URL construction for HTMLFrame component 2025-01-29 01:53:51 +00:00
retoor a8a9815aec fix: move https enforcement before url param construction in HTMLFrame 2025-01-29 01:52:53 +00:00
retoor 7a572b346f fix: correct broken script src attribute in web.html template
The script tag for html-frame.js had a typo where `sr=` was used instead of `src=`, causing the browser to fail loading the resource. This fix restores the correct attribute name so the script loads properly.
2025-01-29 01:48:16 +00:00
retoor 1a227222a1 fix: add missing html-frame.js script tag to web template head 2025-01-29 01:47:00 +00:00
retoor e5468cee5b fix: ensure fullUrl uses https scheme when missing in HTMLFrame url attribute 2025-01-29 01:45:18 +00:00
retoor 19e8853871 fix: show timestamp on last message in chat by extending CSS selector 2025-01-29 00:06:28 +00:00
retoor 43f10f0fc2 feat: add switch-user class to messages when user changes in chat
Add conditional logic to the MessageListElement class that applies a
"switch-user" CSS class to message elements when the current message's
user differs from the previous one, or when it is the first message in
the list. This enables visual separation between messages from different
users in the chat interface.
2025-01-29 00:04:54 +00:00
retoor 918b60261c feat: add CSS rules to hide avatar, author, and time for consecutive messages from same user
Implement conditional visibility for message metadata in chat UI: avatar and author are hidden by default on `.message` and shown only when `.switch-user` class is present; time is hidden unless the next sibling has `.switch-user`, reducing visual clutter for back-to-back messages from the same sender.
2025-01-28 23:55:17 +00:00
retoor c7a8a44b08 fix: increase default query limits from 30 to 60 in BaseService.find and RPCView.get_messages 2025-01-28 23:48:21 +00:00
retoor 90b5a7ba2b feat: add linkifyText method to convert URLs in messages to clickable links 2025-01-28 23:46:11 +00:00
retoor ed142984a0 feat: add black text color to avatar initials in message-list component
The avatar element now explicitly sets its text color to black, ensuring initials remain readable against any background color assigned to the avatar.
2025-01-28 23:35:21 +00:00
retoor 3142e34fdc feat: add UtilService with random_light_hex_color method generating bright pastel hex colors
Introduce a new utility service class that extends BaseService, providing an async method to generate random hex color codes with RGB values constrained between 128 and 255 for consistently light/pastel shades.
2025-01-28 23:33:11 +00:00
retoor bc56c22f56 feat: add user color field and propagate it through chat messages, registration, and session 2025-01-28 23:33:00 +00:00
retoor f982ec7d39 feat: store message metadata as dataset attributes on message elements
Add uid, channel_uid, user_nick, created_at, user_uid, and message as data attributes on each message div element in MessageListElement.createElement, enabling direct access to message properties from the DOM without requiring a separate data lookup.
2025-01-28 20:54:53 +00:00
retoor a2cc824957 fix: reduce padding and margin in chat-messages and message elements for tighter layout 2025-01-28 20:43:48 +00:00
retoor cee87cb251 chore: add display mode and start URL fields to web app manifest 2025-01-28 20:01:51 +00:00
retoor a91f9c7f2b feat: add favicon and fix manifest.json paths in web template
- Add favicon link with snek1.png icon at 32x32 size in web.html head
- Correct manifest.json and base.css href paths from relative to absolute
2025-01-28 19:52:37 +00:00
retoor 1e1250df18 feat: add WebSocket reconnection logic and PWA install prompt support
- Wrap WebSocket creation in try-catch with 1s retry in Chat class
- Add ensureTimer to Socket class for periodic 5s reconnection attempts
- Comment out background and box-shadow in chat message CSS
- Add beforeinstallprompt listener in ChatWindowElement for PWA installation
- Link manifest.json in web.html template for progressive web app support
2025-01-28 19:41:24 +00:00
retoor 831d13fabe feat: deduplicate websocket subscriptions and add notification sound on new message
- Convert subscription list to set in broadcast to prevent duplicate sends to same client
- Add notification sound playback when a new message is received in chat window
- Refactor chat-input element to cache textarea reference and fix event listener binding
2025-01-28 16:37:10 +00:00
retoor 6f13886e04 feat: add notification sound playback on incoming chat messages
- Add binary MP3 sound effect files (alarm1, alarm2, beep1, beep2, beep3) to audio directory
- Integrate app.playSound(0) call in ChatWindowElement message event handler to trigger notification sound when new messages arrive
- Apply minor whitespace and formatting cleanup across app.js, message-list.js, and related files
2025-01-28 16:24:10 +00:00
retoor 60cea6e25a feat: add configurable message parameter to benchMark method
The benchMark method now accepts an optional `message` parameter that defaults to "Benchmark Message" when not provided, replacing the hardcoded "Haha" prefix in sent messages. The console.info call for sendMessage response data has also been removed.
2025-01-28 16:11:30 +00:00
retoor d6fc206b7f fix: add padding-bottom to message list and scroll behavior in chat window 2025-01-28 16:08:18 +00:00
retoor f58c22b9e4 fix: remove debug logging and debounce message dispatch with new Schedule class
- Remove console.info("ROCKSTARTSS") and setTimeout wrapper from chat-window.js scrollIntoView call
- Add messageEventSchedule property to MessageListElement for debouncing message events
- Create new Schedule class in schedule.js with delay, repeat, and cancel methods
- Replace direct dispatchEvent with debounced call via messageEventSchedule.delay()
- Initialize messageEventSchedule with 500ms delay in connectedCallback
- Add schedule.js script tag to web.html template
2025-01-28 14:52:13 +00:00
retoor d921ad14ee fix: add LIMIT 30 to channel_message query in RPCView.get_messages
The query in `get_messages` method of `RPCView` class was changed from an unbounded `SELECT * FROM channel_message` to `SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 30`, enforcing a 30-row cap on all table fetches to prevent unbounded result sets.
2025-01-28 14:32:23 +00:00
retoor 70e19f8a9f fix: enforce 30-record limit on all find queries and add trailing newlines to channel_message service 2025-01-28 14:28:43 +00:00
retoor 01a9360bd9 chore: remove cached bytecode file app.cpython-312.pyc from docs pycache 2025-01-27 04:12:02 +00:00
retoor 18e724f58f fix: remove stale websocket from subscription on send failure and improve cache store log message 2025-01-27 02:48:53 +00:00
retoor a1c5dadd08 feat: remove benchmark script from web.html template
Remove the DOMContentLoaded event listener that called app.benchMark(3) after a 1-second delay, eliminating the automatic benchmarking on page load from the chat interface template.
2025-01-27 02:38:46 +00:00
retoor 477bc04162 chore: remove debug console logs and test message from chat-window and message-list
- Stripped two `console.info` calls in `chat-window.js` that logged channel and messages objects
- Removed the hardcoded test `sendMessage("Grrrrr")` call in the same file
- Deleted a stray `console.info("WIIIIIIIIIIIIIIIIIIIIIIII")` from `message-list.js` event listener
2025-01-27 02:37:04 +00:00
retoor 84ba628c59 fix: disable debug logging and remove console.debug calls in RESTClient and App classes 2025-01-27 02:16:44 +00:00
retoor ad127c9ec0 fix: fetch user uid from database instead of form input in login submit handler 2025-01-27 02:04:43 +00:00
retoor 9689d2e3ad feat: add ChatInputElement web component with textarea and send button
Implement a custom HTML element 'chat-input' that provides a styled textarea and send button. The component dispatches custom events for input, change, and submit actions, with Enter key support for submission and automatic button disabling when the textarea is empty.
2025-01-27 01:58:28 +00:00
retoor 9f43b817bb feat: extract notification creation into dedicated service and refactor chat UI to use custom elements
Extract inline notification logic from ChatService.send into a new NotificationService.create_channel_message method, register NotificationMapper and NotificationService in their respective factories, and refactor the chat frontend to use custom elements (message-list, chat-input) with proper CSS layout and event-driven message rendering.
2025-01-27 01:57:51 +00:00
retoor d92e9276ae fix: add snek.d* to gitignore and set default sqlite db path in app init 2025-01-27 00:06:13 +00:00
retoor 20c4791f54 feat: add ChatWindowElement web component with channel loading and message display
Implement a new custom HTML element 'chat-window' that initializes a shadow DOM, fetches the first available channel via RPC, displays its name in a header, and renders a message-list child element bound to that channel. Also includes initial debug logging of channel data and a test message send on connection.
2025-01-26 23:56:16 +00:00
retoor 5b91187ada feat: add query method to BaseMapper and BaseService, handle socket send exceptions, and integrate chat-window component
- Add async query method to BaseMapper yielding dict records from raw SQL
- Add async query method to BaseService delegating to mapper.query
- Wrap ws.send_json in try/except to prevent broadcast failure on disconnected clients
- Fix localhost WebSocket URL to include port 8081
- Include chat-window.js script in web.html template
- Replace static chat-area markup with custom <chat-window> element
- Implement get_messages RPC method fetching last 30 channel messages with user nicknames
2025-01-26 23:56:06 +00:00
retoor 65fcb13ce1 feat: add dynamic WebSocket URL based on localhost detection in Socket class
- Added `url` property to Socket class to store the computed WebSocket endpoint
- Set URL to `ws://localhost/rpc.ws` when hostname is localhost, otherwise use `wss://` with the current hostname
- Updated WebSocket constructor in connection logic to use `this.url` instead of hardcoded `ws://localhost:8081/rpc.ws`
2025-01-26 21:54:29 +00:00
retoor b2898361fe feat: add real-time chat service with WebSocket broadcasting and message list UI
Implement core chat functionality including ChatService for message creation and notification dispatch, SocketService for WebSocket subscription management and broadcasting, RPC endpoint for client-server communication, and frontend components (message-list, message-list-manager, MessageModel) for rendering messages in real-time across channels.
2025-01-26 21:51:51 +00:00
retoor b71e74336e style: fix trailing whitespace and missing newline in notification mapper and view files 2025-01-26 21:49:37 +00:00
retoor 07d010e2fb feat: add WebSocket RPC endpoint, notification mapper, and dynamic message list UI components
Introduce a new `/rpc.ws` WebSocket route with `RPCView` handler, along with `NotificationMapper` for database persistence. Register `ChannelMessageService`, `ChatService`, and `SocketService` in the service container. Refactor frontend by replacing static chat messages with a `message-list-manager` custom element, adding `Socket` class for WebSocket connection management, and including new JavaScript modules (`models.js`, `message-list.js`, `message-list-manager.js`). Also add a debug print in `ChannelMemberService.create` and expose `services` property on `BaseView`.
2025-01-26 21:48:58 +00:00
retoor edd8605bdd chore: remove unused SimpleNamespace imports and reformat model/service/mapper files with consistent spacing
Remove unused `from types import SimpleNamespace` imports across app.py, mapper/__init__.py, and service/__init__.py. Reformat all model, service, and mapper files to use consistent keyword argument spacing, add missing trailing newlines at end of files, and align multiline dict/function call formatting with PEP 8 standards across channel, channel_member, channel_message, and notification modules.
2025-01-25 21:28:33 +00:00
retoor 52b173245c feat: add channel, channel_member, channel_message mappers and models with service layer
Introduce ChannelMapper, ChannelMemberMapper, ChannelMessageMapper classes and corresponding ChannelModel, ChannelMemberModel, ChannelMessageModel, NotificationModel definitions. Register new mappers and models in get_mappers/get_models factories using Object container. Add ChannelService with create and ensure_public_channel methods, ChannelMemberService with create method handling banned user checks, and ChannelMessageService with create method. Refactor Application to instantiate Cache, flatten services/mappers assignment, and replace LoginFormView/RegisterFormView with LoginView/RegisterView for /login.json and /register.json routes. Extend UserModel with optional nick field.
2025-01-25 21:24:44 +00:00
retoor 355545ec52 feat: add logout view, login validation, and chat websocket client with model refactoring 2025-01-25 04:50:23 +00:00
retoor 61dc7ec820 style: reformat imports, fix whitespace, and standardize dict/return syntax across multiple modules 2025-01-25 02:47:16 +00:00
retoor 7de4963b0a feat: add StatusView endpoint returning session-based user status and login state 2025-01-25 02:46:53 +00:00
retoor 439d1579f1 feat: add encrypted cookie session support with login redirect and REST client
- Add aiohttp-session and cryptography dependencies to pyproject.toml
- Configure EncryptedCookieStorage with static key in Application init
- Implement custom session middleware attaching session to request
- Add login_required flag and redirect logic to BaseView._iter
- Store uid, username, and logged_in flag in session on registration
- Return redirect_url from RegisterFormView.submit for client-side navigation
- Add RESTClient class in app.js with GET/POST methods and debug logging
- Wire GenericForm to redirect on successful submit via saveResult.redirect_url
- Add /status.json route with StatusView placeholder
- Fix UUIDField value property to always return string
- Remove debug console.log statements from generic-form.js
- Update web.html title and logo to "Snek" and include app.js script
2025-01-25 02:46:33 +00:00
retoor 684f7fdbd1 style: reorder imports and apply consistent formatting to docs app module 2025-01-24 22:42:24 +00:00
retoor 8e62c0e7c2 docs: add initial documentation site with app, api, form, and index pages 2025-01-24 22:41:54 +00:00
retoor e3085e9bea style: reformat imports and whitespace across multiple modules for consistent code style 2025-01-24 22:35:44 +00:00
retoor 2c9d4b3941 feat: add docs subapp and migrate MarkdownExtension to snek.system.markdown
- Mount DocsApplication at /docs path in main Application router
- Move MarkdownExtension from jinja_markdown2 to snek.system.markdown module
- Add _allow_harmful_protocols flag and SimpleNamespace import to markdown renderer
- Update index.html link from /docs.html to /docs/docs/
2025-01-24 22:33:36 +00:00
retoor b747dd8a3a feat: add async validation and submission to generic form with username uniqueness check
Implement async to_json and is_valid across form, model, and validator layers to support client-side form validation via fetch. Introduce UsernameField with async uniqueness check against user service count. Wire up services and mappers as SimpleNamespace on app startup. Refactor UserService to extend BaseService and rename create_user to register with email parameter. Fix UserMapper model_class attribute name.
2025-01-24 20:19:03 +00:00
retoor 56309d9c8f docs: add responsive dialog width rule for mobile viewports in style.css 2025-01-24 15:43:57 +00:00
retoor 83f6dfff47 docs: add API documentation page with code examples for user registration, encryption, and view creation
Add three new files: docs.html template extending base layout with back button and markdown frame, docs.md containing API usage examples for user registration, string encryption, and view classes, and docs.py with DocsHTMLView and DocsMDView handlers rendering the respective templates.
2025-01-24 15:34:02 +00:00
retoor d4436d5b91 feat: add docs views and disable caching for markdown rendering
- Add DocsHTMLView and DocsMDView routes for API documentation pages
- Disable time_cache_async decorator on render_template method
- Replace functools.cache with time_cache_async(120) on render_markdown
- Switch compose entrypoint from python -m snek.app to gunicorn with aiohttp worker
- Add API docs link to index.html navigation and update existing link text
2025-01-24 15:33:27 +00:00
retoor 5ab8ed532c feat: add synchronous and async caching utilities in new cache module
Implement a new `src/snek/system/cache.py` module providing two caching mechanisms:
- `cache`: a direct alias for `functools.cache` for synchronous function memoization
- `async_cache`: a custom decorator that caches results of async functions based on positional arguments, using an internal dictionary to store and retrieve previously computed values
2025-01-24 15:09:10 +00:00
retoor 7ce11f323e feat: add 60-second template caching and sync markdown render with functools cache
Add `time_cache_async` decorator to `render_template` in `Application` class for 60-second caching of rendered templates. Refactor `render_markdown` to use a synchronous `@functools.cache` wrapper `render_markdown_sync` to avoid redundant `MarkdownRenderer` instantiation. Remove debug `print` statements from `Validator` and `BaseModel` in `model.py`. Rename form endpoints from `/login-form.json` and `/register-form.json` to `/login.json` and `/register.json` respectively, updating corresponding template references. Inject inline `<style>{{ highlight_styles }}</style>` into `base.html` for syntax highlighting CSS. Append extensive internal documentation to `about.md` covering user registration, encryption, view creation, and form handling.
2025-01-24 15:08:56 +00:00
retoor d4760b50e1 style: replace .center with .dialog layout and add back buttons to templates 2025-01-24 14:20:35 +00:00
retoor b8ad568c8e style: remove sidebar from base template and add responsive centering to login and register forms 2025-01-24 13:47:19 +00:00
retoor e95961eda1 fix: update login and register button URLs to point to .html pages in index template 2025-01-24 13:16:52 +00:00
retoor d4b4adb00d fix: add size attribute support to FancyButton and apply auto sizing to about page back button 2025-01-24 13:15:55 +00:00
retoor 365340259b feat: add browser history back navigation for fancy-button with /back url
Handle the special "/back" and "/back/" URL values in FancyButton click handler to call window.history.back() instead of navigating to a fixed page. Update the about.html template to use "/back" for the Back button, enabling proper browser history traversal.
2025-01-24 13:05:47 +00:00
retoor 8612c7e0c8 chore: add user service layer, markdown support, and rename model classes
- Introduce UserService with create_user method and password hashing
- Add UserMapper and BaseMapper for database operations
- Create service, mapper, and model registry modules with cached factories
- Rename User model class to UserModel for consistency
- Make email field optional in both RegisterForm and UserModel
- Integrate MarkdownExtension into Jinja2 environment
- Add AboutHTMLView and AboutMDView routes with .html and .md endpoints
- Append jinja-markdown2 and mistune to project dependencies
- Implement markdown rendering in html-frame.js for .md file responses
- Create markdown-frame.js custom element for standalone markdown display
- Add style.css with dark theme and orange heading colors
- Update Dockerfile to Python 3.12.8-alpine3.21 and fix CMD syntax
- Extend .gitignore with .resources, .backup*, and docs directories
- Refactor form.py with MIT license header and code documentation
- Add license and documentation headers to system/http.py
- Fix mobile form layout with full-width and full-height constraints
2025-01-24 13:00:10 +00:00
retoor 8236b568d5 feat: replace gunicorn entrypoint with python -m snek.app and add MIT license, pyproject.toml metadata, form/model refactor, and new view structure
Replace the Dockerfile CMD from gunicorn to `python -m snek.app`, add MIT LICENSE.txt, populate pyproject.toml with project metadata and dependencies, refactor forms into `snek.form.login` and `snek.form.register` with new HTMLElement system, delete old `snek/forms.py`, add `snek/model/user.py` with User model, restructure `snek/app.py` imports and router setup to use new view modules, rename `styles.css` to `base.css` and strip comments, add `fancy-button.js` custom element, and update `compose.yml` entrypoint accordingly.
2025-01-24 02:28:43 +00:00
retoor 454d51a678 feat: add restart policy and remove develop watch config in compose.yml
Remove the develop watch configuration block that enabled live code sync
and add a restart: always policy to the snek service for production
deployment readiness.
2025-01-18 12:23:23 +00:00
retoor 1ac7ef8b0d feat: add Dockerfile, compose.yml, gunicorn config, http utilities, and static assets
Add Dockerfile with wkhtmltopdf multi-stage build and gunicorn entrypoint for production deployment. Introduce compose.yml with volume sync for development. Switch Makefile from snek.serve to gunicorn with aiohttp worker. Extend Application class with CORS middleware, static file serving, login/test/http-get/http-photo routes, and template rendering. Create gunicorn.py entrypoint, http.py module with async URL fetching, screenshot generation via imgkit, link repair, and CRC32-based caching. Add middleware.py with CORS handlers. Include static assets: app.js with Message/Room/App classes, html_frame.css/js custom element, and prachtig-gitter_like.html UI template. Update setup.cfg dependencies with beautifulsoup4, gunicorn, imgkit, wkhtmltopdf. Add *.png to .gitignore.
2025-01-18 12:21:38 +00:00
retoor 001b05f862 feat: replace boeh matrix bot with snek slack-like chat application
- Rewrite README to describe new project purpose: a performance-focused Slack-like chat app
- Remove old Matrix bot description and add installation and running instructions
- Add make install and python module run commands for snek.app and snek.models
2025-01-17 22:09:46 +00:00
retoor d0d9e679e1 feat: add initial project structure with snek chat server and registration form
Set up the Python project skeleton including pyproject.toml, setup.cfg, Makefile, .gitignore, and README. Implement the core snek application with aiohttp-based web server, model validation framework, and a registration form with username, email, and password fields.
2025-01-17 22:06:17 +00:00
341 changed files with 33071 additions and 2694 deletions
+3
View File
@@ -1,3 +1,6 @@
snek-container-compose.yml
.r_history
.vscode
.history
.resources
+290
View File
@@ -0,0 +1,290 @@
# Changelog
## Version 1.36.0 - 2026-02-14
Increases the websocket heartbeat interval to reduce network overhead and improve connection efficiency. Loads subscriptions asynchronously to enhance responsiveness during data retrieval.
**Changes:** 1 files, 29 lines
**Languages:** Python (29 lines)
## Version 1.35.0 - 2026-01-31
update py files
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.34.0 - 2026-01-31
update css, html, py files
**Changes:** 44 files, 3802 lines
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
## Version 1.33.0 - 2026-01-24
Modularize RPC action handlers by splitting the monolithic rpc.py file into separate components for authentication, channels, containers, databases, mentions, messages, and miscellaneous actions. This improves code organization and facilitates easier maintenance and extension of RPC functionalities.
**Changes:** 13 files, 2753 lines
**Languages:** Python (2753 lines)
## Version 1.32.0 - 2026-01-24
Adds mention functionality to the chat system, enabling users to mention other users with backend models and services handling the logic. Updates the frontend with navigation for mentions, including modified chat input and new mention navigation components.
**Changes:** 13 files, 438 lines
**Languages:** CSS (75 lines), HTML (1 lines), JavaScript (125 lines), Python (237 lines)
## Version 1.31.0 - 2026-01-24
Reduces the maximum number of workers for channel message processing to 1, limiting concurrency to improve performance in resource-constrained environments.
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.30.0 - 2026-01-17
Increases the maximum number of workers for processing channel messages to 30, enabling better handling of concurrent message loads.
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.29.0 - 2026-01-17
Updates the site's CSS and HTML to refine the visual layout and styling of the index page. These changes enhance readability and user interface consistency without altering core functionality.
**Changes:** 2 files, 297 lines
**Languages:** CSS (293 lines), HTML (4 lines)
## Version 1.28.0 - 2026-01-17
Adds a documentation hub with pages on architecture, design, API, bots, and contributions. Increases the maximum workers in the channel message service to 10 for improved performance and updates navigation links in the about and index pages.
**Changes:** 11 files, 2755 lines
**Languages:** HTML (2719 lines), Python (36 lines)
## Version 1.27.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.26.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.25.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.24.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 60 lines
**Languages:** CSS (44 lines), JavaScript (16 lines)
## Version 1.23.0 - 2026-01-03
update py files
**Changes:** 1 files, 30 lines
**Languages:** Python (30 lines)
## Version 1.22.0 - 2026-01-03
update py files
**Changes:** 1 files, 8 lines
**Languages:** Python (8 lines)
## Version 1.21.0 - 2026-01-03
update py files
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.20.0 - 2026-01-03
update css files
**Changes:** 2 files, 25 lines
**Languages:** CSS (25 lines)
## Version 1.19.0 - 2026-01-03
update css, html, js files
**Changes:** 4 files, 311 lines
**Languages:** CSS (137 lines), HTML (3 lines), JavaScript (171 lines)
## Version 1.18.0 - 2026-01-03
update html, js files
**Changes:** 2 files, 271 lines
**Languages:** HTML (39 lines), JavaScript (232 lines)
## Version 1.17.0 - 2026-01-03
update css, js, py files
**Changes:** 4 files, 17 lines
**Languages:** CSS (11 lines), JavaScript (2 lines), Python (4 lines)
## Version 1.16.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 15 lines
**Languages:** CSS (11 lines), JavaScript (4 lines)
## Version 1.15.0 - 2026-01-03
update css files
**Changes:** 1 files, 19 lines
**Languages:** CSS (19 lines)
## Version 1.14.0 - 2025-12-26
Users can now create, configure settings for, and delete channels through dedicated dialog interfaces. Developers access new RPC methods to support these channel management operations.
**Changes:** 9 files, 801 lines
**Languages:** CSS (127 lines), HTML (517 lines), Python (157 lines)
## Version 1.13.0 - 2025-12-24
Improves performance in the balancer, socket service, and cache by removing async locks. Adds database connection injection for testing in the app and updates pytest configuration.
**Changes:** 5 files, 291 lines
**Languages:** Other (6 lines), Python (285 lines)
## Version 1.12.0 - 2025-12-21
The socket service enhances concurrency and performance through improved locking and asynchronous handling. The RPC view renames the scheduled list to tasks for clearer API terminology.
**Changes:** 2 files, 243 lines
**Languages:** Python (243 lines)
## Version 1.11.0 - 2025-12-19
Adds the ability to configure whether user registration is open or closed via system settings. Administrators can now toggle registration openness to control access to the registration form.
**Changes:** 5 files, 262 lines
**Languages:** HTML (185 lines), Python (77 lines)
## Version 1.10.0 - 2025-12-19
Users must now receive an invitation to register for an account, as open registration is disabled.
**Changes:** 1 files, 6 lines
**Languages:** HTML (6 lines)
## Version 1.9.0 - 2025-12-18
Adds a debug logging option to the serve command for enhanced troubleshooting. Improves error handling across the application and corrects a typo in the ip2location middleware.
**Changes:** 148 files, 1047 lines
**Languages:** JavaScript (299 lines), Python (748 lines)
## Version 1.8.0 - 2025-12-18
The socket service now handles errors more robustly and prevents crashes through improved safety checks. Socket methods support better concurrency and provide enhanced logging for developers.
**Changes:** 4 files, 2279 lines
**Languages:** JavaScript (592 lines), Python (1687 lines)
## Version 1.7.0 - 2025-12-17
Fixes socket cleanup in the websocket handler to prevent resource leaks and improve connection stability.
**Changes:** 1 files, 29 lines
**Languages:** Python (29 lines)
## Version 1.6.0 - 2025-12-17
Removes presence debounce to make user departures instant. Simplifies websocket connection and error handling in RPC views for improved reliability.
**Changes:** 2 files, 98 lines
**Languages:** Python (98 lines)
## Version 1.5.0 - 2025-12-17
remove umami analytics script
**Changes:** 1 files, 1 lines
**Languages:** HTML (1 lines)
## Version 1.4.0 - 2025-12-17
Updates the socket service to improve connection stability and error handling.
**Changes:** 1 files, 4 lines
**Languages:** Python (4 lines)
## Version 1.3.0 - 2025-12-17
Users now receive notifications when other users join or depart the application. Departure notifications are debounced to reduce the frequency of rapid successive alerts.
**Changes:** 5 files, 418 lines
**Languages:** HTML (1 lines), JavaScript (259 lines), Python (158 lines)
## Version 1.2.0 - 2025-12-17
Removes Umami analytics integration, eliminating user tracking functionality. Developers must handle analytics separately if needed.
**Changes:** 2 files, 2 lines
**Languages:** HTML (1 lines), Python (1 lines)
## Version 1.1.0 - 2025-12-17
Fixes potential errors in forum message handling by adding a null check for the star field, preventing crashes when the field is missing. Updates the message list display to handle starred messages more reliably.
**Changes:** 3 files, 10 lines
**Languages:** JavaScript (2 lines), Python (8 lines)
+4 -34
View File
@@ -1,40 +1,10 @@
FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf
FROM python:3.12.8-alpine3.21
FROM python:3.14.0a6-bookworm
RUN mkdir -p /code
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers git
#WKHTMLTOPDFNEEDS
RUN apk add --no-cache \
libstdc++ \
libx11 \
libxrender \
libxext \
libssl3 \
ca-certificates \
fontconfig \
freetype \
ttf-dejavu \
ttf-droid \
ttf-freefont \
ttf-liberation \
# more fonts
&& apk add --no-cache --virtual .build-deps \
msttcorefonts-installer \
# Install microsoft fonts
&& update-ms-fonts \
&& fc-cache -f \
# Clean up when done
&& rm -rf /tmp/* \
&& apk del .build-deps
COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf
COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage
RUN apt update && apt install build-essential docker -y
COPY pyproject.toml pyproject.toml
COPY src src
RUN mkdir /drive
RUN pip install --upgrade pip
RUN pip install -e .
EXPOSE 8081
CMD ["python","-m","snek.app"]
#CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
+1 -1
View File
@@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh
COPY pyproject.toml pyproject.toml
COPY src src
COpy ssh_host_key ssh_host_key
COPY ssh_host_key ssh_host_key
RUN pip install --upgrade pip
RUN pip install -e .
EXPOSE 2225
+18
View File
@@ -0,0 +1,18 @@
FROM ubuntu:latest
RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx tmux -y
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain nightly -y
RUN wget https://retoor.molodetz.nl/api/packages/retoor/generic/r/1.0.0/r
RUN chmod +x r
RUN mv r /usr/local/bin/r
RUN echo 'root:root' | chpasswd
COPY ./terminal /opt/bootstrap
COPY ./terminal /opt/snek
RUN cp -r /root /opt/bootstrap/root
COPY ./terminal/entry /usr/local/bin/entry
+22 -5
View File
@@ -6,12 +6,29 @@ GUNICORN_WORKERS = 1
PORT = 8081
install:
python3 -m venv .venv
$(PIP) install -e .
shell:
.venv/bin/snek shell
dump:
@$(PYTHON) -m snek.dump
build:
serve: run
run:
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
.venv/bin/snek serve
maintenance:
.venv/bin/snek maintenance
install: ubuntu
python3.12 -m venv .venv
$(PIP) install -e .
ubuntu:
docker build -f DockerfileUbuntu -t snek_ubuntu .
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUB7PQvHZD6v8hfxeaDbU3hC0nGQQwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MDYxOTUzMDhaFw0yNjA0
MDYxOTUzMDhaMEUxCzAJBgNVBAYTAk5MMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCtYf8PP7QjRJOfK6zmfAZhSKwMowCSYijKeChxsgyn
hDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXesBBPhie+4KmtsykiI7QEHXVVrWHba
6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71Fme4ofJ2Plb7PnF53R4Tc3aTMdIW
HrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrldPHYbTGvBcDUil7qZ8hZ8ZxLMzu3
GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSyVz+fVgvLozNL9kV89hbZo7H/M37O
zmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZyXgXgdMdLAgMBAAGjUzBRMB0GA1Ud
DgQWBBQtGeiVTYjzWb2hTqJwipRVXU1LnzAfBgNVHSMEGDAWgBQtGeiVTYjzWb2h
TqJwipRVXU1LnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAc
1BacrGMlCd5nfYuvQfv0DdTVGc2FSqxPMRGrZKfjvjemgPMs0+DqUwCJiR6oEOGb
atOYoIBX9KGXSUKRYYc/N75bslwfV1CclNqd2mPxULfks/D8cAzf2mgw4kYSaDHs
tJkywBe9L6eIK4cQ5YJvutVNVKMYPi+9w+wKog/FafkamFfX/3SLCkGmV0Vv4g0q
Ro9KmTTQpJUvd63X8bONLs1t8p+HQfWmKlhuVn5+mncNdGREe8dbciXE5FKu8luN
dr/twoTZTPhmIHPmVEeNxS8hFSiu0iUPTO0HcCAODILGbtXbClA+1Z0ukiRfUya6
tgVuEk0c64L86qGP7Ply
-----END CERTIFICATE-----
+4 -14
View File
@@ -2,27 +2,17 @@ services:
snek:
build: .
restart: always
privileged: true
ports:
- "8081:8081"
volumes:
- ./:/code
- /media/storage/snek.molodetz.nl/drive:/code/drive
- /media/storage/snek.molodetz.nl/drive:/drive
- /var/run/docker.sock:/var/run/docker.sock
environment:
- PYTHONDONTWRITEBYTECODE=1
- PYTHONUNBUFFERED=1
entrypoint: ["gunicorn", "-w", "1", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
#entrypoint: ["python","-m","snek.app"]
snecssh:
build:
context: .
dockerfile: DockerfileDrive
restart: always
ports:
- "2225:2225"
volumes:
- ./:/code
environment:
- PYTHONDONTWRITEBYTECODE="1"
- PYTHONUNBUFFERED="1"
entrypoint: ["python","-m","snekssh.app2"]
#["python","-m","snek.app"]
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf
K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes
BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71
Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl
dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy
Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy
XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1
NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER
TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q
RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC
1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG
eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F
aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C
+DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz
OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl
v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB
orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs
eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s
qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F
8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC
L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC
DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E
9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv
9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45
5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3
xhhX7RsDOYMFKmn59DS1CQCZAA==
-----END PRIVATE KEY-----
+31 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.0.0"
version = "1.36.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
@@ -15,8 +15,11 @@ keywords = ["chat", "snek", "molodetz"]
requires-python = ">=3.12"
dependencies = [
"mkdocs>=1.4.0",
"lxml",
"IPython",
"shed",
"app @ git+https://retoor.molodetz.nl/retoor/app",
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
"beautifulsoup4",
"gunicorn",
"imgkit",
@@ -28,6 +31,31 @@ dependencies = [
"asyncssh",
"emoji",
"aiofiles",
"PyJWT"
"PyJWT",
"multiavatar",
"gitpython",
'uvloop; platform_system != "Windows"',
"humanize",
"Pillow",
"pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk",
"bcrypt"
]
[tool.setuptools.packages.find]
where = ["src"] # <-- this changed
[tool.setuptools.package-data]
"*" = ["*.*"]
[project.scripts]
snek = "snek.__main__:main"
[project.optional-dependencies]
test = [
"pytest",
"pytest-asyncio",
"pytest-aiohttp"
]
+15
View File
@@ -0,0 +1,15 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--strict-markers
--disable-warnings
--tb=short
-v
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
asyncio_mode = auto
+55
View File
@@ -0,0 +1,55 @@
# Project Quality Review: Snek
## Overview
The Snek project is a comprehensive web-based application, functioning as a collaborative platform or chat system with extensive features including user management, channels, repositories, containers, real-time communication via WebSockets, Docker integration, Git support, and more. It leverages Python (primarily with aiohttp for the backend) and JavaScript for the frontend. The codebase is organized into modules such as app, mapper, model, service, view, system, and static assets. This review is based on a literal examination of every Python file in `src/snek/*` recursively, as their full contents have been provided.
## Strengths
- **Modular Architecture**: The code exhibits strong separation of concerns with dedicated modules for mappers (data access), models (data structures), services (business logic), views (HTTP/WebSocket handlers), and system utilities. This promotes maintainability and scalability.
- **Asynchronous Support**: Extensive use of async/await throughout (e.g., in `src/snek/app.py`, `src/snek/service/socket.py`, `src/snek/view/rpc.py`), effectively handling I/O-bound operations like WebSockets, database queries, and external API calls.
- **Feature-Rich**: Supports a wide array of features, including user authentication, file uploads, terminal emulation, Docker container management, Git repositories, WebDAV, SSH, forums, push notifications, and avatar generation. Integration with external tools like Docker and Git is notable.
- **Frontend Components**: Custom JavaScript components (e.g., in `src/snek/static/njet.js`) provide a modern, component-based UI with event handling and REST clients.
- **Caching and Utilities**: Robust caching implementation (e.g., in `src/snek/system/cache.py`) with LRU eviction, and utility services (e.g., `src/snek/service/util.py`) enhance performance and reusability.
- **Model and Field System**: A sophisticated model system (e.g., in `src/snek/system/model.py`) with typed fields (e.g., UUIDField, CreatedField) ensures data integrity and validation.
- **Extensibility**: The codebase includes hooks for extensions (e.g., Jinja2 extensions in `src/snek/system/markdown.py`, `src/snek/system/template.py`) and sub-applications (e.g., forum in `src/snek/forum.py`).
- **Error Handling in Places**: Some areas show good error handling (e.g., try-except in `src/snek/service/socket.py`, `src/snek/view/rpc.py`), though not universal.
## Weaknesses
- **Syntax Errors**:
- In `src/snek/research/serpentarium.py`, line 25: `self.setattr(self, "db", self.get)` `setattr` is misspelled; it should be `setattr`. Additionally, the line ends with `)`, which is misplaced and causes a syntax error. Line 26: `self.setattr(self, "db", self.set)` similar misspelling and incorrect assignment (self.set is not defined).
- In `src/snek/sync.py`, line 25: `self.setattr(self, "db", self.get)` `setattr` misspelled and misplaced `)`. Line 26: `self.setattr(self, "db", self.set)` same issues. Line 27: `super()` called without arguments, which may fail in classes with multiple inheritance or if arguments are expected.
- **Security Concerns**:
- **Injection Vulnerabilities**: Raw SQL queries without parameterization (e.g., in `src/snek/service/channel.py`, `src/snek/service/user.py`, `src/snek/view/rpc.py`) risk SQL injection. For example, in `src/snek/service/channel.py`, queries like `f"SELECT ... WHERE channel_uid=:channel_uid {history_start_filter}"` use string formatting, but not all are parameterized.
- **Authentication Gaps**: Some WebSocket and RPC endpoints lack visible authentication checks (e.g., in `src/snek/view/rpc.py`, methods like `echo` and `query` don't enforce login). Basic auth in `src/snek/webdav.py` and `src/snek/sssh.py` is present but hardcoded.
- **Input Validation**: Minimal input sanitization (e.g., in `src/snek/system/form.py`, forms have placeholders but no regex or length checks enforced). User inputs in RPC calls (e.g., `src/snek/view/rpc.py`) are not validated.
- **Hardcoded Secrets**: Database paths (e.g., 'sqlite:///snek.db' in multiple files), keys (e.g., SESSION_KEY in `src/snek/app.py`), and credentials are hardcoded, posing risks if exposed.
- **Privilege Escalation**: Admin checks are inconsistent (e.g., in `src/snek/service/channel.py`, `src/snek/view/rpc.py`), and some operations (e.g., clearing channels) only check `is_admin` without further validation.
- **WebDAV and SSH**: `src/snek/webdav.py` and `src/snek/sssh.py` handle file access but lack rate limiting or detailed permission checks.
- **Code Quality Issues**:
- **Inconsistent Naming and Style**: Mixed camelCase and snake_case (e.g., `set_typing` vs. `generateUniqueId` in JS). Some classes have similar names (e.g., `DatasetWebSocketView` in research files).
- **Lack of Documentation**: Few docstrings or comments (e.g., no docstrings in `src/snek/service/user.py`, `src/snek/view/channel.py`). Methods are often self-explanatory but lack context.
- **Hardcoded Values**: URLs, paths, and constants are hardcoded (e.g., in `src/snek/static/njet.js`, `src/snek/app.py`).
- **Global State and Side Effects**: Use of global variables (e.g., in `src/snek/system/markdown.py`, `src/snek/view/rpc.py`) and mutable defaults can lead to bugs.
- **Performance Issues**: No visible optimization for large datasets (e.g., in `src/snek/service/channel_message.py`, queries could be inefficient). Process pools in `src/snek/service/channel_message.py` are per-message, potentially wasteful.
- **Error Handling Gaps**: Many methods lack try-except (e.g., in `src/snek/system/docker.py`, `src/snek/system/terminal.py`). Exceptions are sometimes caught but not logged properly.
- **Dependencies**: Imports like `dataset`, `git`, `pymongo` (implied) are not version-pinned, risking compatibility issues.
- **Testing Absence**: No visible unit or integration tests in the codebase.
- **Potential Bugs**:
- In `src/snek/research/serpentarium.py`, `self.setattr(self, "db", self.set)` assigns undefined `self.set`.
- In `src/snek/sync.py`, similar assignment issues.
- In `src/snek/view/rpc.py`, `self.user_uid` property assumes `self.view.session.get("uid")`, but no null checks.
- In `src/snek/system/docker.py`, `ComposeFileManager` uses subprocess without full error handling.
- In `src/snek/service/channel_message.py`, executor pools per UID could lead to resource leaks if not cleaned up.
- In `src/snek/forum.py`, event listeners are added but no removal logic is visible.
- **Maintainability**: Large files (e.g., `src/snek/view/rpc.py` is over 1000 lines) and complex methods (e.g., in `src/snek/app.py`) make refactoring hard. Some code duplication (e.g., WebSocket handling in multiple views).
## Recommendations
- **Fix Syntax Errors Immediately**: Correct `setattr` spellings, remove misplaced `)`, and fix `super()` calls in `src/snek/research/serpentarium.py` and `src/snek/sync.py` to prevent runtime failures.
- **Enhance Security**: Implement parameterized queries, add input validation (e.g., using regex in `src/snek/system/form.py`), enforce authentication in all endpoints, and use environment variables for secrets.
- **Improve Code Quality**: Add docstrings, comments, and consistent naming. Refactor large methods and remove hardcoded values.
- **Add Error Handling and Testing**: Wrap risky operations in try-except, log errors, and introduce unit tests (e.g., using pytest).
- **Optimize Performance**: Review query efficiency, add indexing, and manage resources (e.g., executor pools).
- **Address Bugs**: Fix undefined assignments and add null checks.
- **General**: Pin dependencies, review for race conditions in async code, and consider code reviews or linters (e.g., flake8, mypy).
## Grade
B- (Solid foundation with good architecture and features, but critical syntax errors, security vulnerabilities, and quality issues require immediate attention to avoid production risks.)
BIN
View File
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
# retoor <retoor@molodetz.nl>
"""
MIT License
Copyright (c) 2025 retoor
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Author: retoor <retoor@molodetz.nl>
Description: Utility to load environment variables from a .env file.
"""
import os
from typing import Optional
def load_env(file_path: str = '.env') -> None:
"""
Loads environment variables from a specified file into the current process environment.
Args:
file_path (str): Path to the environment file. Defaults to '.env'.
Returns:
None
Raises:
FileNotFoundError: If the specified file does not exist.
IOError: If an I/O error occurs during file reading.
"""
try:
with open(file_path, 'r') as env_file:
for line in env_file:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Skip lines without '='
if '=' not in line:
continue
if line.startswith('export '):
line = line[7:]
key, value = line.split('=', 1)
# Set environment variable
os.environ[key.strip()] = value.strip()
except FileNotFoundError:
raise FileNotFoundError(f"Environment file '{file_path}' not found.")
except IOError as e:
raise IOError(f"Error reading environment file '{file_path}': {e}")
try:
load_env()
except Exception as e:
pass
+148
View File
@@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import shutil
import sqlite3
import click
from aiohttp import web
from snek.app import Application
from snek.shell import Shell
logging.basicConfig(level=logging.INFO)
@click.group()
def cli():
pass
@cli.command()
def export():
app = Application(db_path="sqlite:///snek.db")
async def fix_message(message):
message = {
"uid": message["uid"],
"user_uid": message["user_uid"],
"text": message["message"],
"sent": message["created_at"],
}
user = await app.services.user.get(uid=message["user_uid"])
message["user"] = user and user["username"] or None
return (message["user"] or "") + ": " + (message["text"] or "")
async def run():
result = []
for channel in app.db["channel"].find(
is_private=False, is_listed=True, tag="public"
):
print(f"Dumping channel: {channel['label']}.")
result += [
await fix_message(record)
for record in app.db["channel_message"].find(
channel_uid=channel["uid"], order_by="created_at"
)
]
print("Dump succesfull!")
print("Converting to json.")
print("Converting succesful, now writing to dump.txt")
with open("dump.txt", "w") as f:
f.write("\n\n".join(result))
print("Dump written to dump.json")
asyncio.run(run())
@cli.command()
def statistics():
async def run():
app = Application(db_path="sqlite:///snek.db")
app.services.statistics.database()
asyncio.run(run())
@cli.command()
def maintenance():
async def run():
app = Application(db_path="sqlite:///snek.db")
await app.services.container.maintenance()
await app.services.channel_message.maintenance()
asyncio.run(run())
@cli.command()
@click.option(
"--db_path", default="snek.db", help="Database to initialize if not exists."
)
@click.option("--source", default=None, help="Database to initialize if not exists.")
def init(db_path, source):
if source and pathlib.Path(source).exists():
print(f"Copying {source} to {db_path}")
shutil.copy2(source, db_path)
print("Database initialized.")
return
if pathlib.Path(db_path).exists():
return
print(f"Initializing database at {db_path}")
db = sqlite3.connect(db_path)
db.cursor().executescript(
pathlib.Path(__file__).parent.joinpath("schema.sql").read_text()
)
db.commit()
db.close()
print("Database initialized.")
@cli.command()
@click.option(
"--port", default=8081, show_default=True, help="Port to run the application on"
)
@click.option(
"--host",
default="0.0.0.0",
show_default=True,
help="Host to run the application on",
)
@click.option(
"--db_path",
default="snek.db",
show_default=True,
help="Database path for the application",
)
@click.option(
"--debug",
is_flag=True,
default=False,
help="Enable debug logging",
)
def serve(port, host, db_path, debug):
if debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("snek").setLevel(logging.DEBUG)
web.run_app(Application(db_path=f"sqlite:///{db_path}"), port=port, host=host)
@cli.command()
@click.option(
"--db_path",
default="snek.db",
show_default=True,
help="Database path for the application",
)
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()
if __name__ == "__main__":
main()
+468 -39
View File
@@ -1,10 +1,17 @@
import pathlib
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import ssl
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from datetime import datetime
from ipaddress import ip_address
import logging
logging.basicConfig(level=logging.DEBUG)
import IP2Location
from aiohttp import web
from aiohttp_session import (
get_session as session_get,
@@ -13,27 +20,99 @@ from aiohttp_session import (
)
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
from snek.docs.app import Application as DocsApplication
from snek import snode
from snek.mapper import get_mappers
from snek.service import get_services
from snek.sgit import GitApplication
from snek.sssh import start_ssh_server
from snek.system import http
from snek.system.cache import Cache
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import cors_middleware
from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.config import config
from snek.system.profiler import profiler_handler
from snek.system.template import (
EmojiExtension,
LinkifyExtension,
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
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.site import ArchitectureView, DesignView, ApiView, BotsView, ContributeView
from snek.view.drive import DriveApiView, DriveView
from snek.view.channel import ChannelDriveApiView
from snek.view.container import ContainerView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.logout import LogoutView
from snek.view.push import PushView
from snek.view.register import RegisterView
from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView
from snek.view.threads import ThreadsView
from snek.view.settings.containers import (
ContainersCreateView,
ContainersDeleteView,
ContainersIndexView,
ContainersUpdateView,
)
from snek.view.settings.index import SettingsIndexView
from snek.view.settings.profile import SettingsProfileView
from snek.view.settings.repositories import (
RepositoriesCreateView,
RepositoriesDeleteView,
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.admin import (
AdminIndexView,
AdminUsersIndexView,
AdminUserEditView,
AdminUserBanView,
AdminChannelsIndexView,
AdminChannelMembersView,
AdminChannelEditView,
AdminChannelClearView,
AdminChannelDeleteView,
AdminMessagesIndexView,
AdminForumsIndexView,
AdminThreadsView,
AdminPostsView,
AdminFilesIndexView,
AdminDriveItemsView,
AdminRepositoriesView,
AdminNotificationsIndexView,
AdminNotificationCreateView,
AdminSystemIndexView,
AdminKVView,
AdminPushView,
)
from snek.view.status import StatusView
from snek.view.web import WebView
from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
from snek.view.search_user import SearchUserView
from snek.system.profiler import profiler_handler
from snek.view.user import UserView
from snek.view.web import WebView
from snek.webdav import WebdavApplication
from snek.forum import setup_forum
from snek.system.template import whitelist_attributes
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
@@ -44,37 +123,187 @@ async def session_middleware(request, handler):
response = await handler(request)
return response
class Application(BaseApplication):
def __init__(self, *args, **kwargs):
@web.middleware
async def ip2location_middleware(request, handler):
response = await handler(request)
ip = request.headers.get("X-Forwarded-For", request.remote)
try:
ipaddr = ip_address(ip)
if ipaddr.is_private:
return response
except ValueError:
return response
if not request.session.get("uid"):
return response
user = await request.app.services.user.get(uid=request.session.get("uid"))
if not user:
return response
location = request.app.ip2location.get_all(ip)
if user["city"] != location.city:
user["country_long"] = location.country_long
user["country_short"] = location.country_short
user["city"] = location.city
user["region"] = location.region
user["latitude"] = location.latitude
user["longitude"] = location.longitude
user["ip"] = ip
await request.app.services.user.update(user)
return response
@web.middleware
async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"):
raise web.HTTPFound(request.path + "/")
return await handler(request)
class Application(BaseApplication):
async def create_default_forum(self, app):
forums = [f async for f in self.services.forum.find(is_active=True)]
if not forums:
# Find admin user to be the creator
admin_user = await self.services.user.get(is_admin=True)
if admin_user:
await self.services.forum.create_forum(
name="General Discussion",
description="A place for general discussion.",
created_by_uid=admin_user["uid"],
)
def __init__(self, *args, db_connection=None, **kwargs):
middlewares = [
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
csp_middleware,
]
self._test_db = db_connection
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5,
**kwargs,
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
self.tasks = asyncio.Queue()
self._middlewares.append(session_middleware)
self._middlewares.append(auth_middleware)
self.jinja2_env.add_extension(MarkdownExtension)
self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension)
self.setup_router()
self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off")
if not self.db["user"].has_index("username"):
self.db["user"].create_index("username", unique=True)
if not self.db["channel_member"].has_index(["channel_uid","user_uid"]):
self.db["channel_member"].create_index(["channel_uid","user_uid"])
if not self.db["channel_message"].has_index(["channel_uid","user_uid"]):
self.db["channel_message"].create_index(["channel_uid","user_uid"])
self.jinja2_env.filters["sanitize"] = sanitize_html
self.time_start = datetime.now()
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
self.forum = None
self.ssh_server = None
self.sync_service = None
self.executor = None
self.cache = Cache(self)
self.config = config
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
)
self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
self.on_startup.append(self.create_default_forum)
@property
def db(self):
if self._test_db is not None:
return self._test_db
return self._db
@db.setter
def db(self, value):
self._db = value
@property
def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds()
@property
def uptime(self):
return self._format_uptime(self.uptime_seconds)
def _format_uptime(self, seconds):
seconds = int(seconds)
days, seconds = divmod(seconds, 86400)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
parts = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes > 0:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds > 0 or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
return ", ".join(parts)
async def start_user_availability_service(self, app):
app.user_availability_service_task = asyncio.create_task(
app.services.socket.user_availability_service()
)
async def snode_sync(self, app):
self.sync_service = asyncio.create_task(snode.sync_service(app))
async def start_ssh_server(self, app):
app.ssh_server = await start_ssh_server(app, app.ssh_host, app.ssh_port)
if app.ssh_server:
asyncio.create_task(app.ssh_server.wait_closed())
async def prepare_asyncio(self, app):
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
async def create_task(self, task):
await self.tasks.put(task)
async def task_runner(self):
while True:
task = await self.tasks.get()
self.db.begin()
try:
await task
self.tasks.task_done()
except Exception as ex:
logger.error(f"Task runner error: {ex}")
self.db.commit()
async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off")
try:
if not self.db["user"].has_index("username"):
self.db["user"].create_index("username", unique=True)
if not self.db["channel_member"].has_index(["channel_uid", "user_uid"]):
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
except Exception as ex:
logger.warning(f"Index creation error: {ex}")
await self.services.drive.prepare_all()
self.loop.create_task(self.task_runner())
def setup_router(self):
self.router.add_get("/", IndexView)
@@ -84,37 +313,117 @@ 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)
self.router.add_view("/about.md", AboutMDView)
self.router.add_view("/logout.json", LogoutView)
self.router.add_view("/logout.html", LogoutView)
self.router.add_view("/docs.html", DocsHTMLView)
self.router.add_view("/docs.md", DocsMDView)
self.router.add_view("/architecture.html", ArchitectureView)
self.router.add_view("/design.html", DesignView)
self.router.add_view("/api.html", ApiView)
self.router.add_view("/bots.html", BotsView)
self.router.add_view("/contribute.html", ContributeView)
self.router.add_view("/status.json", StatusView)
self.router.add_view("/settings/index.html", SettingsIndexView)
self.router.add_view("/settings/profile.html", SettingsProfileView)
self.router.add_view("/settings/profile.json", SettingsProfileView)
self.router.add_view("/push.json", PushView)
self.router.add_view("/web.html", WebView)
self.router.add_view("/login.html", LoginView)
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.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)
self.router.add_get("/http-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_view("/channel/{channel}.html", WebView)
self.add_subapp(
"/docs",
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
)
self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)
self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView)
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/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
)
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
self.router.add_view(
"/settings/repositories/create.html", RepositoriesCreateView
)
self.router.add_view(
"/settings/repositories/repository/{name}/update.html",
RepositoriesUpdateView,
)
self.router.add_view(
"/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(
"/settings/containers/container/{uid}/update.html", ContainersUpdateView
)
self.router.add_view(
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
)
self.router.add_view("/admin/index.html", AdminIndexView)
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
self.router.add_view("/admin/system/kv.html", AdminKVView)
self.router.add_view("/admin/system/push.html", AdminPushView)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
setup_forum(self)
async def handle_test(self, request):
return await self.render_template(
"test.html", request, context={"name": "retoor"}
return await whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
)
async def handle_http_get(self, request: web.Request):
@@ -129,16 +438,136 @@ class Application(BaseApplication):
body=path.read_bytes(), headers={"Content-Type": "image/png"}
)
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
return await super().render_template(template, request, context)
start_time = time.perf_counter()
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
context["config"] = self.config
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
)
last_message = await parent_object.get_last_message()
color = None
if last_message:
last_message_user = await last_message.get_user()
color = last_message_user["color"]
item["color"] = color
item["last_message_on"] = parent_object["last_message_on"]
item["is_private"] = parent_object["tag"] == "dm"
if other_user:
item["name"] = other_user["nick"]
item["uid"] = subscribed_channel["channel_uid"]
else:
item["name"] = subscribed_channel["label"]
item["uid"] = subscribed_channel["channel_uid"]
item["new_count"] = subscribed_channel["new_count"]
channels.append(item)
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
if "channels" not in context:
context["channels"] = channels
if "user" not in context:
context["user"] = await self.services.user.get(
request.session.get("uid")
)
self.original_loader = self.jinja2_env.loader
self.jinja2_env.loader = await self.get_user_template_loader(
request.session.get("uid")
)
try:
context["nonce"] = request["csp_nonce"]
except KeyError:
context["nonce"] = "?"
rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader
end_time = time.perf_counter()
logger.debug(f"render_template took {end_time - start_time:.4f} seconds")
return rendered
async def static_handler(self, request):
file_name = request.match_info.get("filename", "")
paths = []
uid = request.session.get("uid")
if uid:
user_static_path = await self.services.user.get_static_path(uid)
if user_static_path:
paths.append(user_static_path)
for admin_uid in self.services.user.get_admin_uids():
user_static_path = await self.services.user.get_static_path(admin_uid)
if user_static_path:
paths.append(user_static_path)
paths.append(self.static_path)
for path in paths:
if pathlib.Path(path).joinpath(file_name).exists():
return web.FileResponse(pathlib.Path(path).joinpath(file_name))
return web.HTTPNotFound()
async def get_user_template_loader(self, uid=None):
template_paths = []
for admin_uid in self.services.user.get_admin_uids():
user_template_path = await self.services.user.get_template_path(admin_uid)
if user_template_path:
template_paths.append(user_template_path)
if uid:
user_template_path = await self.services.user.get_template_path(uid)
if user_template_path:
template_paths.append(user_template_path)
template_paths.append(self.template_path)
return FileSystemLoader(template_paths)
@asynccontextmanager
async def no_save(self):
stats = {"count": 0}
async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0])
stats["count"] = stats["count"] + 1
logger.debug(f"save is ignored {stats['count']} times")
return args[0]
save_original = self.services.channel_message.mapper.save
self.services.channel_message.mapper.save = patched_save
raised_exception = None
try:
yield
except Exception as ex:
raised_exception = ex
finally:
self.services.channel_message.mapper.save = save_original
if raised_exception:
raise raised_exception
app = Application(db_path="sqlite:///snek.db")
async def main():
await web._run_app(app, port=8081, host="0.0.0.0")
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain("cert.pem", "key.pem")
await web._run_app(app, port=8081, host="0.0.0.0", ssl_context=ssl_context)
if __name__ == "__main__":
asyncio.run(main())
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
import asyncio
import sys
class LoadBalancer:
def __init__(self, backend_ports):
self.backend_ports = backend_ports
self.backend_processes = []
self.client_counts = [0] * len(backend_ports)
async def start_backend_servers(self, port, workers):
for x in range(workers):
port += 1
process = await asyncio.create_subprocess_exec(
sys.executable,
sys.argv[0],
"backend",
str(port),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
port += 1
self.backend_processes.append(process)
print(
f"Started backend server on port {(port-1)/port} with PID {process.pid}"
)
async def handle_client(self, reader, writer):
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ("127.0.0.1", self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
async def forward(r, w):
try:
while True:
data = await r.read(1024)
if not data:
break
w.write(data)
await w.drain()
except asyncio.CancelledError:
pass
finally:
w.close()
task1 = asyncio.create_task(forward(reader, backend_writer))
task2 = asyncio.create_task(forward(backend_reader, writer))
await asyncio.gather(task1, task2)
except Exception as e:
print(f"Error: {e}")
finally:
writer.close()
self.client_counts[server_index] -= 1
async def monitor(self):
while True:
await asyncio.sleep(5)
print("Connected clients per server:")
for i, count in enumerate(self.client_counts):
print(f"Server {self.backend_ports[i]}: {count} clients")
async def start(self, host="0.0.0.0", port=8081, workers=5):
await self.start_backend_servers(port, workers)
server = await asyncio.start_server(self.handle_client, host, port)
asyncio.create_task(self.monitor())
# Handle shutdown gracefully
try:
async with server:
await server.serve_forever()
except asyncio.CancelledError:
pass
finally:
# Terminate backend processes
for process in self.backend_processes:
process.terminate()
await asyncio.gather(*(p.wait() for p in self.backend_processes))
print("Backend processes terminated.")
async def backend_echo_server(port):
async def handle_echo(reader, writer):
try:
while True:
data = await reader.read(1024)
if not data:
break
writer.write(data)
await writer.drain()
except Exception:
pass
finally:
writer.close()
server = await asyncio.start_server(handle_echo, "127.0.0.1", port)
print(f"Backend echo server running on port {port}")
await server.serve_forever()
async def main():
backend_ports = [8001, 8003, 8005, 8006]
# Launch backend echo servers
# Wait a moment for servers to start
lb = LoadBalancer(backend_ports)
await lb.start()
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == "backend":
port = int(sys.argv[2])
from snek.app import Application
snek = Application(port=port)
web.run_app(snek, port=port, host="127.0.0.1")
elif sys.argv[1] == "sync":
web.run_app(snek, port=port, host="127.0.0.1")
else:
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Shutting down...")
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
import pathlib
from aiohttp import web
+41
View File
@@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
import asyncio
from snek.app import app
async def fix_message(message):
message = {
"uid": message["uid"],
"user_uid": message["user_uid"],
"text": message["message"],
"sent": message["created_at"],
}
user = await app.services.user.get(uid=message["user_uid"])
message["user"] = user and user["username"] or None
return (message["user"] or "") + ": " + (message["text"] or "")
async def dump_public_channels():
result = []
for channel in app.db["channel"].find(
is_private=False, is_listed=True, tag="public"
):
print(f"Dumping channel: {channel['label']}.")
result += [
await fix_message(record)
for record in app.db["channel_message"].find(
channel_uid=channel["uid"], order_by="created_at"
)
]
print("Dump succesfull!")
print("Converting to json.")
print("Converting succesful, now writing to dump.json")
with open("dump.txt", "w") as f:
f.write("\n\n".join(result))
print("Dump written to dump.json")
if __name__ == "__main__":
asyncio.run(dump_public_channels())
+3
View File
@@ -0,0 +1,3 @@
# retoor <retoor@molodetz.nl>
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
+2 -1
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
@@ -16,4 +18,3 @@ class SearchUserForm(Form):
action = FormButtonElement(
name="action", value="submit", text="Search", type="button"
)
+27
View File
@@ -0,0 +1,27 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
class SettingsProfileForm(Form):
nick = FormInputElement(
name="nick",
required=True,
place_holder="Your Nickname",
min_length=1,
max_length=20,
)
action = FormButtonElement(
name="action", value="submit", text="Save", type="button"
)
title = HTMLElement(tag="h1", text="Profile")
profile = FormInputElement(
name="profile",
place_holder="Tell about yourself.",
required=False,
max_length=300,
)
action = FormButtonElement(
name="action", value="submit", text="Save", type="button"
)
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
class ForumApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.render_template = self.parent.render_template
# Set up routes
self.setup_routes()
# Set up notification listeners
self.setup_notifications()
@property
def db(self):
return self.parent.db
@property
def services(self):
return self.parent.services
def setup_routes(self):
"""Set up all forum routes"""
# API routes
self.router.add_view("/index.html", ForumIndexView)
self.router.add_route("GET", "/api/forums", ForumView.get_forums)
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread)
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post)
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like)
self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin)
self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock)
# WebSocket route
self.router.add_view("/ws", ForumWebSocketView)
# Static HTML route
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
def setup_notifications(self):
"""Set up notification listeners for WebSocket broadcasting"""
# Forum notifications
self.services.forum.add_notification_listener("forum_created", self.on_forum_event)
# Thread notifications
self.services.thread.add_notification_listener("thread_created", self.on_thread_event)
# Post notifications
self.services.post.add_notification_listener("post_created", self.on_post_event)
self.services.post.add_notification_listener("post_edited", self.on_post_event)
self.services.post.add_notification_listener("post_deleted", self.on_post_event)
# Like notifications
self.services.post_like.add_notification_listener("post_liked", self.on_like_event)
self.services.post_like.add_notification_listener("post_unliked", self.on_like_event)
async def on_forum_event(self, event_type, data):
"""Handle forum events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_thread_event(self, event_type, data):
"""Handle thread events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_post_event(self, event_type, data):
"""Handle post events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_like_event(self, event_type, data):
"""Handle like events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
return await self.parent.render_template("forum.html", request)
# Integration with main app
def setup_forum(app):
"""Set up forum sub-application"""
forum_app = ForumApplication(app)
app.add_subapp("/forum", forum_app)
app.forum_app = forum_app
# Register models and services if needed
# This would typically be done in your main app initialization
return forum_app
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.app import app
application = app
+25 -3
View File
@@ -1,12 +1,22 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.mapper.channel import ChannelMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.channel_member import ChannelMemberMapper
from snek.mapper.channel_message import ChannelMessageMapper
from snek.mapper.notification import NotificationMapper
from snek.mapper.user import UserMapper
from snek.mapper.drive import DriveMapper
from snek.mapper.container import ContainerMapper
from snek.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper
from snek.mapper.notification import NotificationMapper
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.mapper.mention import MentionMapper
from snek.system.object import Object
@@ -21,9 +31,21 @@ def get_mappers(app=None):
"notification": NotificationMapper(app=app),
"drive_item": DriveItemMapper(app=app),
"drive": DriveMapper(app=app),
"user_property": UserPropertyMapper(app=app),
"repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
"push": PushMapper(app=app),
"forum": ForumMapper(app=app),
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
"profile_page": ProfilePageMapper(app=app),
"mention": MentionMapper(app=app),
}
)
def get_mapper(name, app=None):
return get_mappers(app=app)[name]
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel import ChannelModel
from snek.system.mapper import BaseMapper
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper
class ChannelAttachmentMapper(BaseMapper):
table_name = "channel_attachment"
model_class = ChannelAttachmentModel
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.container import Container
from snek.system.mapper import BaseMapper
class ContainerMapper(BaseMapper):
model_class = Container
table_name = "container"
+4 -2
View File
@@ -1,7 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive import DriveModel
from snek.system.mapper import BaseMapper
class DriveMapper(BaseMapper):
table_name = 'drive'
model_class = DriveModel
table_name = "drive"
model_class = DriveModel
+6 -3
View File
@@ -1,7 +1,10 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper
from snek.model.drive_item import DriveItemModel
class DriveItemMapper(BaseMapper):
model_class = DriveItemModel
table_name = 'drive_item'
table_name = "drive_item"
+25
View File
@@ -0,0 +1,25 @@
# retoor <retoor@molodetz.nl>
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper
class ForumMapper(BaseMapper):
table_name = "forum"
model_class = ForumModel
class ThreadMapper(BaseMapper):
table_name = "thread"
model_class = ThreadModel
class PostMapper(BaseMapper):
table_name = "post"
model_class = PostModel
class PostLikeMapper(BaseMapper):
table_name = "post_like"
model_class = PostLikeModel
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.mention import MentionModel
from snek.system.mapper import BaseMapper
class MentionMapper(BaseMapper):
table_name = "mention"
model_class = MentionModel
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.profile_page import ProfilePageModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class ProfilePageMapper(BaseMapper):
table_name = "profile_page"
model_class = ProfilePageModel
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper
class PushMapper(BaseMapper):
model_class = PushRegistrationModel
table_name = "push_registration"
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.repository import RepositoryModel
from snek.system.mapper import BaseMapper
class RepositoryMapper(BaseMapper):
model_class = RepositoryModel
table_name = "repository"
+19
View File
@@ -1,7 +1,26 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.user import UserModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class UserMapper(BaseMapper):
table_name = "user"
model_class = UserModel
def get_admin_uids(self):
try:
return [
user["uid"]
for user in self.db.query(
"SELECT uid FROM user WHERE is_admin = :is_admin",
{"is_admin": True},
)
]
except Exception as ex:
logger.warning(f"Failed to get admin uids: {ex}")
return []
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.model.user_property import UserPropertyModel
from snek.system.mapper import BaseMapper
class UserPropertyMapper(BaseMapper):
table_name = "user_property"
model_class = UserPropertyModel
+23
View File
@@ -1,11 +1,22 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.model.channel import ChannelModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.channel_member import ChannelMemberModel
# from snek.model.channel_message import ChannelMessageModel
from snek.model.channel_message import ChannelMessageModel
from snek.model.container import Container
from snek.model.drive import DriveModel
from snek.model.drive_item import DriveItemModel
from snek.model.notification import NotificationModel
from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel
from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.object import Object
@@ -17,6 +28,18 @@ def get_models():
"channel_member": ChannelMemberModel,
"channel": ChannelModel,
"channel_message": ChannelMessageModel,
"drive_item": DriveItemModel,
"drive": DriveModel,
"notification": NotificationModel,
"user_property": UserPropertyModel,
"repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel,
"container": Container,
"push_registration": PushRegistrationModel,
"forum": ForumModel,
"thread": ThreadModel,
"post": PostModel,
"post_like": PostLikeModel,
}
)
+28
View File
@@ -1,3 +1,6 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
@@ -9,3 +12,28 @@ class ChannelModel(BaseModel):
is_private = ModelField(name="is_private", required=True, kind=bool, value=False)
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
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 = ""
if self["history_start"]:
history_start_filter = f" AND created_at > '{self['history_start']}' "
try:
async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
{"channel_uid": self["uid"]},
):
return await self.app.services.channel_message.get(uid=model["uid"])
except Exception:
pass
return None
async def get_members(self):
return await self.app.services.channel_member.find(
channel_uid=self["uid"], deleted_at=None, is_banned=False
)
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class ChannelAttachmentModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
path = ModelField(name="path", required=True, kind=str)
size = ModelField(name="size", required=False, kind=int)
user_uid = ModelField(name="user_uid", required=True, kind=str)
mime_type = ModelField(name="type", required=True, kind=str)
relative_url = ModelField(name="relative_url", required=True, kind=str)
resource_type = ModelField(
name="resource_type", required=True, kind=str, value="file"
)
+28
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@@ -13,3 +15,29 @@ class ChannelMemberModel(BaseModel):
)
is_muted = ModelField(name="is_muted", required=True, kind=bool, value=False)
is_banned = ModelField(name="is_banned", required=True, kind=bool, value=False)
new_count = ModelField(name="new_count", required=False, kind=int, value=0)
async def get_user(self):
return await self.app.services.user.get(uid=self["user_uid"])
async def get_channel(self):
return await self.app.services.channel.get(uid=self["channel_uid"])
async def get_name(self):
channel = await self.get_channel()
if channel["tag"] == "dm":
user = await self.get_other_dm_user()
return user["nick"]
return channel["name"] or self["label"]
async def get_other_dm_user(self):
channel = await self.get_channel()
if channel["tag"] != "dm":
return None
async for model in self.app.services.channel_member.find(
channel_uid=channel["uid"]
):
if model["uid"] != self["uid"]:
return await self.app.services.user.get(uid=model["user_uid"])
return await self.get_user()
+19
View File
@@ -1,3 +1,8 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
@@ -6,3 +11,17 @@ class ChannelMessageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
message = ModelField(name="message", required=True, kind=str)
html = ModelField(name="html", required=False, kind=str)
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self):
return int(
(
datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])
).total_seconds()
)
async def get_user(self) -> UserModel:
return await self.app.services.user.get(uid=self["user_uid"])
async def get_channel(self):
return await self.app.services.channel.get(uid=self["channel_uid"])
+13
View File
@@ -0,0 +1,13 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class Container(BaseModel):
id = ModelField(name="id", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
status = ModelField(name="status", required=True, kind=str)
resources = ModelField(name="resources", required=False, kind=str)
user_uid = ModelField(name="user_uid", required=False, kind=str)
path = ModelField(name="path", required=False, kind=str)
readonly = ModelField(name="readonly", required=False, kind=bool, default=False)
+11 -2
View File
@@ -1,7 +1,16 @@
from snek.system.model import BaseModel,ModelField
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class DriveModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True)
name = ModelField(name="name", required=False, type=str)
@property
async def items(self):
async for drive_item in self.app.services.drive_item.find(
drive_uid=self["uid"]
):
yield drive_item
+22 -6
View File
@@ -1,9 +1,25 @@
from snek.system.model import BaseModel,ModelField
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.model import BaseModel, ModelField
class DriveItemModel(BaseModel):
drive_uid = ModelField(name="drive_uid", required=True,kind=str)
name = ModelField(name="name", required=True,kind=str)
path = ModelField(name="path", required=True,kind=str)
file_type = ModelField(name="file_type", required=True,kind=str)
file_size = ModelField(name="file_size", required=True,kind=int)
drive_uid = ModelField(name="drive_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
path = ModelField(name="path", required=True, kind=str)
file_type = ModelField(name="file_type", required=True, kind=str)
file_size = ModelField(name="file_size", required=True, kind=int)
is_available = ModelField(
name="is_available", required=True, kind=bool, initial_value=True
)
@property
def extension(self):
return self["name"].split(".")[-1]
@property
def mime_type(self):
mimetype, _ = mimetypes.guess_type(self["name"])
return mimetype
+101
View File
@@ -0,0 +1,101 @@
# retoor <retoor@molodetz.nl>
# models/forum.py
from snek.system.model import BaseModel, ModelField
class ForumModel(BaseModel):
"""Forum categories"""
name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
description = ModelField(name="description", required=False, kind=str, max_length=500)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True)
icon = ModelField(name="icon", required=False, kind=str)
position = ModelField(name="position", required=True, kind=int, value=0)
is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
async def get_threads(self, limit=50, offset=0):
async for thread in self.app.services.thread.find(
forum_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
order_by="-last_post_at"
#order_by="is_pinned DESC, last_post_at DESC"
):
yield thread
async def increment_thread_count(self):
self["thread_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
await self.save()
# models/thread.py
class ThreadModel(BaseModel):
"""Forum threads"""
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
view_count = ModelField(name="view_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
async def get_posts(self, limit=50, offset=0):
async for post in self.app.services.post.find(
thread_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
order_by="created_at"
):
yield post
async def increment_view_count(self):
self["view_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
self["last_post_at"] = self.app.services.get_timestamp()
await self.save()
# models/post.py
class PostModel(BaseModel):
"""Forum posts"""
thread_uid = ModelField(name="thread_uid", required=True, kind=str)
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
edited_at = ModelField(name="edited_at", required=False, kind=str)
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False)
like_count = ModelField(name="like_count", required=True, kind=int, value=0)
async def get_author(self):
return await self.app.services.user.get(uid=self["created_by_uid"])
async def is_liked_by(self, user_uid):
return await self.app.services.post_like.exists(
post_uid=self["uid"],
user_uid=user_uid
)
# models/post_like.py
class PostLikeModel(BaseModel):
"""Post likes"""
post_uid = ModelField(name="post_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class MentionModel(BaseModel):
message_uid = ModelField(name="message_uid", required=True, kind=str)
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
mentioned_user_uid = ModelField(name="mentioned_user_uid", required=True, kind=str)
author_user_uid = ModelField(name="author_user_uid", required=True, kind=str)
read_at = ModelField(name="read_at", required=False, kind=str)
+2
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
+14
View File
@@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import BaseModel, ModelField
logger = logging.getLogger(__name__)
class ProfilePageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str)
slug = ModelField(name="slug", required=True, kind=str)
content = ModelField(name="content", required=False, kind=str, value="")
order_index = ModelField(name="order_index", required=True, kind=int, value=0)
is_published = ModelField(name="is_published", required=True, kind=bool, value=True)
+10
View File
@@ -0,0 +1,10 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class PushRegistrationModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True)
endpoint = ModelField(name="endpoint", required=True)
key_auth = ModelField(name="key_auth", required=True)
key_p256dh = ModelField(name="key_p256dh", required=True)
+14
View File
@@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class RepositoryModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
description = ModelField(name="description", required=False, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)
+41 -4
View File
@@ -1,3 +1,5 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@@ -18,10 +20,7 @@ class UserModel(BaseModel):
regex=r"^[a-zA-Z0-9_-+/]+$",
)
color = ModelField(
name ="color",
required=True,
regex=r"^#[0-9a-fA-F]{6}$",
kind=str
name="color", required=True, regex=r"^#[0-9a-fA-F]{6}$", kind=str
)
email = ModelField(
name="email",
@@ -31,3 +30,41 @@ class UserModel(BaseModel):
password = ModelField(name="password", required=True, min_length=1)
last_ping = ModelField(name="last_ping", required=False, kind=str)
is_admin = ModelField(name="is_admin", required=False, kind=bool)
country_short = ModelField(name="country_short", required=False, kind=str)
country_long = ModelField(name="country_long", required=False, kind=str)
city = ModelField(name="city", required=False, kind=str)
latitude = ModelField(name="latitude", required=False, kind=float)
longitude = ModelField(name="longitude", required=False, kind=float)
region = ModelField(name="region", required=False, kind=str)
ip = ModelField(name="ip", required=False, kind=str)
async def get_property(self, name):
prop = await self.app.services.user_property.find_one(
user_uid=self["uid"], name=name
)
if prop:
return prop["value"]
async def has_property(self, name):
return await self.app.services.user_property.exists(
user_uid=self["uid"], name=name
)
async def set_property(self, name, value):
if not await self.has_property(name):
await self.app.services.user_property.insert(
user_uid=self["uid"], name=name, value=value
)
else:
await self.app.services.user_property.update(
user_uid=self["uid"], name=name, value=value
)
async def get_channel_members(self):
async for channel_member in self.app.services.channel_member.find(
user_uid=self["uid"], is_banned=False, deleted_at=None
):
yield channel_member
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
value = ModelField(name="value", required=True, kind=str)
+58
View File
@@ -0,0 +1,58 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
class DatasetMethod:
pass
class DatasetTable:
pass
class WebSocketClient2:
def __init__(self, uri):
self.uri = uri
self.loop = asyncio.get_event_loop()
self.websocket = None
self.receive_queue = asyncio.Queue()
def send(self, message: str):
pass
def close(self):
pass
class DatasetWrapper:
def __init__(self):
pass
def commit(self):
pass
def query(self, *args, **kwargs):
pass
class DatasetWebSocketView:
def __init__(self):
self.ws = None
def format_result(self, result):
pass
async def send_str(self, msg):
pass
def get(self, key):
pass
def set(self, key, value):
pass
async def run_server():
pass
+56
View File
@@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
import time
from concurrent.futures import ProcessPoolExecutor
import snek.serpentarium
durations = []
def task1():
global durations
client = snek.serpentarium.DatasetWrapper()
start = time.time()
for x in range(1500):
client["a"].delete()
client["a"].insert({"foo": x})
client["a"].find(foo=x)
client["a"].find_one(foo=x)
client["a"].count()
# print(client['a'].find(foo=x) )
# print(client['a'].find_one(foo=x) )
# print(client['a'].count())
client.close()
duration1 = f"{time.time()-start}"
durations.append(duration1)
print(durations)
with ProcessPoolExecutor(max_workers=4) as executor:
tasks = [
executor.submit(task1),
executor.submit(task1),
executor.submit(task1),
executor.submit(task1),
]
for task in tasks:
task.result()
import dataset
client = dataset.connect("sqlite:///snek.db")
start = time.time()
for x in range(1500):
client["a"].delete()
client["a"].insert({"foo": x})
print([dict(row) for row in client["a"].find(foo=x)])
print(dict(client["a"].find_one(foo=x)))
print(client["a"].count())
duration2 = f"{time.time()-start}"
print(duration1, duration2)
+149
View File
@@ -0,0 +1,149 @@
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 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,
label TEXT,
last_message_on TEXT,
tag TEXT,
uid TEXT,
updated_at TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
is_banned BOOLEAN,
is_moderator BOOLEAN,
is_muted BOOLEAN,
is_read_only BOOLEAN,
label TEXT,
new_count BIGINT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
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 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,
message TEXT,
object_type TEXT,
object_uid TEXT,
read_at TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
CREATE TABLE drive (
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,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
CREATE TABLE profile_page (
id INTEGER NOT NULL,
content TEXT,
created_at TEXT,
deleted_at TEXT,
is_published BOOLEAN,
order_index BIGINT,
slug TEXT,
title TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);
+54
View File
@@ -0,0 +1,54 @@
const channelUid = "{{ channel.uid.value }}";
function initInputField(textBox) {
textBox.addEventListener('change', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
});
textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (message) {
app.rpc.sendMessage(channelUid, message);
e.target.value = '';
}
}
});
textBox.focus();
}
function updateTimes() {
document.querySelectorAll(".time").forEach((time) => {
time.innerText = app.timeDescription(time.dataset.created_at);
});
}
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const messagesContainer = document.querySelector(".chat-messages");
let isLoadingExtra = false;
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
setInterval(updateTimes, 1000);
app.addEventListener("channel-message", (data) => {
if (data.channel_uid !== channelUid) {
if(!isMentionForSomeoneElse(data.message)){
channelSidebar.notify(data);
}
}
});
+54 -16
View File
@@ -1,35 +1,73 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.service.admin import AdminService
from snek.service.channel import ChannelService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.channel_member import ChannelMemberService
from snek.service.channel_message import ChannelMessageService
from snek.service.chat import ChatService
from snek.service.notification import NotificationService
from snek.service.socket import SocketService
from snek.service.user import UserService
from snek.service.util import UtilService
from snek.service.container import ContainerService
from snek.service.db import DBService
from snek.service.drive import DriveService
from snek.service.drive_item import DriveItemService
from snek.service.notification import NotificationService
from snek.service.push import PushService
from snek.service.repository import RepositoryService
from snek.service.socket import SocketService
from snek.service.user import UserService
from snek.service.user_property import UserPropertyService
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
from snek.service.mention import MentionService
_service_registry = {}
def register_service(name, service_cls):
_service_registry[name] = service_cls
register = register_service
@functools.cache
def get_services(app):
return Object(
result = Object(
**{
"user": UserService(app=app),
"channel_member": ChannelMemberService(app=app),
"channel": ChannelService(app=app),
"channel_message": ChannelMessageService(app=app),
"chat": ChatService(app=app),
"socket": SocketService(app=app),
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app)
name: service_cls(app=app)
for name, service_cls in _service_registry.items()
}
)
result.register = register_service
return result
def get_service(name, app=None):
return get_services(app=app)[name]
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
register_service("channel_message", ChannelMessageService)
register_service("chat", ChatService)
register_service("socket", SocketService)
register_service("notification", NotificationService)
register_service("util", UtilService)
register_service("drive", DriveService)
register_service("drive_item", DriveItemService)
register_service("user_property", UserPropertyService)
register_service("repository", RepositoryService)
register_service("db", DBService)
register_service("channel_attachment", ChannelAttachmentService)
register_service("container", ContainerService)
register_service("push", PushService)
register_service("statistics", StatisticsService)
register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)
register_service("mention", MentionService)
register_service("admin", AdminService)
+577
View File
@@ -0,0 +1,577 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime
from typing import Any
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class AdminService(BaseService):
mapper_name = None
async def _require_admin(self, user_uid: str) -> bool:
user = await self.services.user.get(uid=user_uid)
if not user or not user["is_admin"]:
raise PermissionError("Admin access required")
return True
async def get_dashboard_stats(self) -> dict[str, Any]:
logger.info("Fetching dashboard statistics")
user_count = await self.services.user.count(deleted_at=None)
channel_count = await self.services.channel.count(deleted_at=None)
message_count = await self.services.channel_message.count(deleted_at=None)
forum_count = await self.services.forum.count(deleted_at=None)
thread_count = await self.services.thread.count(deleted_at=None)
post_count = await self.services.post.count(deleted_at=None)
connected_users = await self.services.socket.get_connected_users()
online_count = len(connected_users)
drive_count = await self.services.drive.count(deleted_at=None)
repository_count = await self.services.repository.count(deleted_at=None)
notification_count = await self.services.notification.count(deleted_at=None)
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
return {
"users": user_count,
"channels": channel_count,
"messages": message_count,
"forums": forum_count,
"threads": thread_count,
"posts": post_count,
"online": online_count,
"drives": drive_count,
"repositories": repository_count,
"notifications": notification_count,
}
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
logger.info(f"Fetching recent activity, limit={limit}")
messages = []
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:100] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
logger.debug(f"Fetched {len(messages)} recent messages")
return messages
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
users = []
async for user in self.services.user.find(**filters):
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
continue
users.append({
"uid": user["uid"],
"username": user["username"],
"nick": user["nick"],
"is_admin": user["is_admin"] or False,
"is_banned": user["is_banned"] or False,
"created_at": str(user["created_at"]) if user["created_at"] else None,
"last_online": str(user["last_online"]) if user["last_online"] else None,
"city": user["city"],
"country_short": user["country_short"],
})
total = await self.services.user.count(deleted_at=None)
logger.debug(f"Found {len(users)} users, total={total}")
return {"users": users, "total": total, "page": page, "per_page": per_page}
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
for field in allowed_fields:
if field in updates:
if field == "is_admin" and target_uid == admin_uid:
raise ValueError("Cannot modify own admin status")
user[field] = updates[field]
await self.services.user.save(user)
logger.info(f"User {target_uid} updated by admin {admin_uid}")
return {"success": True}
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} banning user {target_uid}")
if admin_uid == target_uid:
raise ValueError("Cannot ban yourself")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = True
await self.services.user.save(user)
logger.info(f"User {target_uid} banned by admin {admin_uid}")
return {"success": True}
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = False
await self.services.user.save(user)
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
return {"success": True}
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
if len(new_password) != 6:
raise ValueError("Password must be exactly 6 characters")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["password"] = new_password
await self.services.user.save(user)
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
return {"success": True}
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
channels = []
async for channel in self.services.channel.find(**filters):
if search and search.lower() not in (channel["name"] or "").lower():
continue
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
channels.append({
"uid": channel["uid"],
"name": channel["name"],
"description": channel["description"] or "",
"tag": channel["tag"],
"is_private": channel["is_private"] or False,
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
"member_count": member_count,
"message_count": message_count,
})
total = await self.services.channel.count(deleted_at=None)
logger.debug(f"Found {len(channels)} channels, total={total}")
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
async def get_channel_members(self, channel_uid: str) -> list[dict]:
logger.info(f"Getting members for channel {channel_uid}")
members = []
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
user = await self.services.user.get(uid=member["user_uid"])
members.append({
"uid": member["uid"],
"user_uid": member["user_uid"],
"username": user["username"] if user else "Unknown",
"nick": user["nick"] if user else "Unknown",
"is_owner": member["is_owner"] or False,
"is_banned": member["is_banned"] or False,
"joined_at": str(member["created_at"]) if member["created_at"] else None,
})
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
return members
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
allowed_fields = ["name", "description", "is_private"]
for field in allowed_fields:
if field in updates:
channel[field] = updates[field]
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
if channel["name"] == "public":
raise ValueError("Cannot delete the public channel")
channel["deleted_at"] = datetime.now()
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
return {"success": True}
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
deleted_count = 0
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
deleted_count += 1
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
return {"success": True, "deleted_count": deleted_count}
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
if channel_uid:
filters["channel_uid"] = channel_uid
if user_uid:
filters["user_uid"] = user_uid
messages = []
async for message in self.services.channel_message.find(**filters):
if query and query.lower() not in (message["message"] or "").lower():
continue
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:200] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
total = await self.services.channel_message.count(deleted_at=None)
logger.debug(f"Found {len(messages)} messages")
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
message = await self.services.channel_message.get(uid=message_uid)
if not message:
raise ValueError("Message not found")
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing forums: page={page}, per_page={per_page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
forums = []
async for forum in self.services.forum.find(**filters):
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"] or "",
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
"thread_count": thread_count,
})
total = await self.services.forum.count(deleted_at=None)
logger.debug(f"Found {len(forums)} forums, total={total}")
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
allowed_fields = ["name", "description", "is_active"]
for field in allowed_fields:
if field in updates:
forum[field] = updates[field]
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
forum["deleted_at"] = datetime.now()
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
threads = []
async for thread in self.services.thread.find(**filters):
user = await self.services.user.get(uid=thread["created_by_uid"])
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
threads.append({
"uid": thread["uid"],
"title": thread["title"],
"is_pinned": thread["is_pinned"] or False,
"is_locked": thread["is_locked"] or False,
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": thread["created_by_uid"],
"post_count": post_count,
})
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
logger.debug(f"Found {len(threads)} threads, total={total}")
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_pinned"] = not (thread["is_pinned"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
return {"success": True, "is_pinned": thread["is_pinned"]}
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_locked"] = not (thread["is_locked"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
return {"success": True, "is_locked": thread["is_locked"]}
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["deleted_at"] = datetime.now()
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
posts = []
async for post in self.services.post.find(**filters):
user = await self.services.user.get(uid=post["created_by_uid"])
posts.append({
"uid": post["uid"],
"content": post["content"][:200] if post["content"] else "",
"created_at": str(post["created_at"]) if post["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": post["created_by_uid"],
})
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
logger.debug(f"Found {len(posts)} posts, total={total}")
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
post = await self.services.post.get(uid=post_uid)
if not post:
raise ValueError("Post not found")
post["deleted_at"] = datetime.now()
await self.services.post.save(post)
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
drives = []
async for drive in self.services.drive.find(**filters):
user = await self.services.user.get(uid=drive["user_uid"])
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
drives.append({
"uid": drive["uid"],
"name": drive["name"] or "Drive",
"user_uid": drive["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
"item_count": item_count,
})
total = await self.services.drive.count(deleted_at=None)
logger.debug(f"Found {len(drives)} drives, total={total}")
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing items for drive {drive_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
items = []
async for item in self.services.drive_item.find(**filters):
items.append({
"uid": item["uid"],
"name": item["name"] or "",
"path": item["path"] or "",
"size": item["size"] or 0,
"mime_type": item["mime_type"] or "",
"created_at": str(item["created_at"]) if item["created_at"] else None,
})
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
logger.debug(f"Found {len(items)} items, total={total}")
return {"items": items, "total": total, "page": page, "per_page": per_page}
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
item = await self.services.drive_item.get(uid=item_uid)
if not item:
raise ValueError("Drive item not found")
item["deleted_at"] = datetime.now()
await self.services.drive_item.save(item)
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
repositories = []
async for repo in self.services.repository.find(**filters):
user = await self.services.user.get(uid=repo["user_uid"])
repositories.append({
"uid": repo["uid"],
"name": repo["name"],
"user_uid": repo["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_private": repo["is_private"] or False,
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
})
total = await self.services.repository.count(deleted_at=None)
logger.debug(f"Found {len(repositories)} repositories, total={total}")
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
repo = await self.services.repository.get(uid=repo_uid)
if not repo:
raise ValueError("Repository not found")
repo["deleted_at"] = datetime.now()
await self.services.repository.save(repo)
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing notifications: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
notifications = []
async for notif in self.services.notification.find(**filters):
user = await self.services.user.get(uid=notif["user_uid"])
notifications.append({
"uid": notif["uid"],
"message": (notif["message"] or "")[:100],
"type": notif["type"] or "",
"user_uid": notif["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_read": notif["is_read"] or False,
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
})
total = await self.services.notification.count(deleted_at=None)
logger.debug(f"Found {len(notifications)} notifications, total={total}")
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} sending mass notification")
if not message or len(message) > 500:
raise ValueError("Message must be between 1 and 500 characters")
sent_count = 0
if user_uids:
target_users = user_uids
else:
target_users = []
async for user in self.services.user.find(deleted_at=None, is_banned=False):
target_users.append(user["uid"])
for user_uid in target_users:
await self.services.notification.create(
user_uid=user_uid,
message=message,
notification_type="admin",
)
sent_count += 1
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
return {"success": True, "sent_count": sent_count}
async def get_kv_entries(self) -> list[dict]:
logger.info("Fetching KV store entries")
entries = []
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
entries.append({"key": row["key"], "value": row["value"]})
logger.debug(f"Found {len(entries)} KV entries")
return entries
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
if not key or len(key) > 255:
raise ValueError("Key must be between 1 and 255 characters")
if len(value) > 10000:
raise ValueError("Value must be less than 10000 characters")
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
logger.info(f"KV entry set: key={key}")
return {"success": True}
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
self.app.db["kv"].delete(key=key)
logger.info(f"KV entry deleted: key={key}")
return {"success": True}
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing push registrations: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
registrations = []
async for reg in self.services.push.find(**filters):
user = await self.services.user.get(uid=reg["user_uid"])
registrations.append({
"uid": reg["uid"],
"user_uid": reg["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
})
total = await self.services.push.count(deleted_at=None)
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
reg = await self.services.push.get(uid=registration_uid)
if not reg:
raise ValueError("Push registration not found")
reg["deleted_at"] = datetime.now()
await self.services.push.save(reg)
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
return {"success": True}
async def run_maintenance(self, admin_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} running maintenance")
self.app.db.query("VACUUM")
self.app.db.query("ANALYZE")
logger.info("Maintenance completed: VACUUM and ANALYZE")
return {"success": True, "message": "Database maintenance completed"}
+56 -30
View File
@@ -1,30 +1,46 @@
# retoor <retoor@molodetz.nl>
import pathlib
from datetime import datetime
from snek.system.model import now
from snek.system.service import BaseService
from datetime import datetime
from snek.system.model import now
class ChannelService(BaseService):
mapper_name = "channel"
async def get(
self,
uid=None,
**kwargs):
async def get_home_folder(self, channel_uid):
folder = pathlib.Path(f"./drive/{channel_uid}/container/home")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return folder
async def get_attachment_folder(self, channel_uid, ensure=False):
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
if ensure:
path.mkdir(parents=True, exist_ok=True)
return path
async def get(self, uid=None, **kwargs):
if uid:
kwargs['uid'] = uid
result = await super().get(**kwargs)
if result:
return result
del kwargs['uid']
kwargs['name'] = uid
kwargs["uid"] = uid
result = await super().get(**kwargs)
if result:
return result
kwargs['name'] = '#' + uid
del kwargs["uid"]
kwargs["name"] = uid
result = await super().get(**kwargs)
if result:
return result
return None
kwargs["name"] = "#" + uid
result = await super().get(**kwargs)
if result:
return result
return None
return await super().get(**kwargs)
async def create(
@@ -49,42 +65,46 @@ class ChannelService(BaseService):
model["is_private"] = is_private
model["is_listed"] = is_listed
if await self.save(model):
await self.services.container.create(model["uid"])
return model
raise Exception(f"Failed to create channel: {model.errors}.")
async def get_dm(self, user1, user2):
channel_member = await self.services.channel_member.get_dm(
user1, user2
)
channel_member = await self.services.channel_member.get_dm(user1, user2)
if channel_member:
return await self.get(uid=channel_member["channel_uid"])
channel = await self.create(
"DM", user1, tag="dm"
)
await self.services.channel_member.create_dm(
channel["uid"], user1, user2
)
return channel
channel = await self.create("DM", user1, tag="dm")
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
return channel
async def get_recent_users(self, channel_uid):
async for user in self.query(
"SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30",
{"channel_uid": channel_uid},
):
yield user
async def get_users(self, channel_uid):
users = []
async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid,
is_banned=False,
is_muted=False,
deleted_at=None,
):
user = await self.services.user.get(uid=channel_member["user_uid"])
user = await self.services.user.get(uid=channel_member["user_uid"])
if user:
yield user
async def get_online_users(self, channel_uid):
users = []
async for user in self.get_users(channel_uid):
if not user["last_ping"]:
continue
if (datetime.fromisoformat(now()) - datetime.fromisoformat(user["last_ping"])).total_seconds() < 20:
yield user
if (
datetime.fromisoformat(now())
- datetime.fromisoformat(user["last_ping"])
).total_seconds() < 180:
yield user
async def get_for_user(self, user_uid):
async for channel_member in self.services.channel_member.find(
@@ -93,7 +113,13 @@ class ChannelService(BaseService):
deleted_at=None,
):
channel = await self.get(uid=channel_member["channel_uid"])
yield channel
yield channel
async def clear(self, channel_uid):
model = await self.get(uid=channel_uid)
model['history_from'] = datetime.now()
await self.save(model)
async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public")
+27
View File
@@ -0,0 +1,27 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.service import BaseService
class ChannelAttachmentService(BaseService):
mapper_name = "channel_attachment"
async def create_file(self, channel_uid, user_uid, name):
attachment = await self.new()
attachment["channel_uid"] = channel_uid
attachment["user_uid"] = user_uid
attachment["name"] = name
attachment["mime_type"] = mimetypes.guess_type(name)[0]
attachment["resource_type"] = "file"
real_file_name = f"{attachment['uid']}-{name}"
attachment["relative_url"] = f"{attachment['uid']}-{name}"
attachment_folder = await self.services.channel.get_attachment_folder(
channel_uid
)
attachment_path = attachment_folder.joinpath(real_file_name)
attachment["path"] = str(attachment_path)
if await self.save(attachment):
return attachment
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
+35 -14
View File
@@ -1,3 +1,6 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import now
from snek.system.service import BaseService
@@ -5,6 +8,19 @@ class ChannelMemberService(BaseService):
mapper_name = "channel_member"
async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member)
async def get_user_uids(self, channel_uid):
async for model in self.mapper.query(
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid AND deleted_at IS NULL AND is_banned = 0",
{"channel_uid": channel_uid},
):
yield model["user_uid"]
async def create(
self,
channel_uid,
@@ -16,7 +32,7 @@ class ChannelMemberService(BaseService):
):
model = await self.get(channel_uid=channel_uid, user_uid=user_uid)
if model:
if model['is_banned']:
if model["is_banned"]:
return False
return model
model = await self.new()
@@ -31,26 +47,31 @@ class ChannelMemberService(BaseService):
if await self.save(model):
return model
raise Exception(f"Failed to create channel member: {model.errors}.")
async def get_dm(self,from_user, to_user):
async for model in self.query("SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user) WHERE channel_member.user_uid=:from_user " ,dict(from_user=from_user, to_user=to_user)):
async def get_dm(self, from_user, to_user):
async for model in self.query(
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
return None
if not from_user == to_user:
return None
async for model in self.query(
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
async def get_other_dm_user(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel = await self.services.channel.get(uid=channel_member['channel_uid'])
channel = await self.services.channel.get(uid=channel_member["channel_uid"])
if channel["tag"] != "dm":
return None
async for model in self.services.channel_member.find(channel_uid=channel_uid):
if model["uid"] != channel_member['uid']:
if model["uid"] != channel_member["uid"]:
return await self.services.user.get(uid=model["user_uid"])
async def create_dm(self,channel_uid, from_user_uid, to_user_uid):
async def create_dm(self, channel_uid, from_user_uid, to_user_uid):
result = await self.create(channel_uid, from_user_uid)
await self.create(channel_uid, to_user_uid)
return result
return result
+177 -36
View File
@@ -1,71 +1,212 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import pathlib
from concurrent.futures import ProcessPoolExecutor
from snek.system.service import BaseService
import jinja2
from snek.system.template import sanitize_html
logger = logging.getLogger(__name__)
jinja2_env = None
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template = jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, message):
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)
logger.debug(f"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):
while True:
changed = 0
async for message in self.find(is_final=False):
message["is_final"] = True
await self.save(message)
changed += 1
async for message in self.find(is_final=None):
message["is_final"] = False
await self.save(message)
changed += 1
if not changed:
break
async def create(self, channel_uid, user_uid, message, is_final=True):
logger.info(f"create: channel_uid={channel_uid}, user_uid={user_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
context = {
}
record = model.record
model["is_final"] = is_final
context = {}
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
context.update(dict(
user_uid=user['uid'],
username=user['username'],
user_nick=user['nick'],
color=user['color']
))
if not user:
logger.error(f"create: user not found user_uid={user_uid}")
raise Exception("User not found")
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
loop = asyncio.get_event_loop()
try:
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
context = json.loads(json.dumps(context, default=str))
logger.debug(f"create: rendering html for message uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
except Exception as ex:
print(ex,flush=True)
if await self.save(model):
logger.error(f"create: html rendering failed: {ex}")
logger.debug(f"create: saving message uid={model['uid']}")
if await super().save(model):
if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index(
["is_final", "user_uid", "channel_uid"]
):
self.mapper.db["channel_message"].create_index(
["is_final", "user_uid", "channel_uid"], unique=False
)
if not self.mapper.db["channel_message"].has_index(["uid"]):
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
self.mapper.db["channel_message"].create_index(
["deleted_at"], unique=False
)
self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
logger.info(f"create: message created successfully uid={model['uid']}, channel={channel_uid}")
return model
logger.error(f"create: failed to save message channel={channel_uid}, errors={model.errors}")
raise Exception(f"Failed to create channel message: {model.errors}.")
async def to_extended_dict(self, message):
user = await self.services.user.get(uid=message["user_uid"])
if not user:
return {}
#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'],
"color": user["color"],
"user_uid": message["user_uid"],
"channel_uid": message["channel_uid"],
"user_nick": user['nick'],
"user_nick": user["nick"],
"message": message["message"],
"created_at": message["created_at"],
"html": message['html'],
"username": user['username']
"html": message["html"],
"username": user["username"],
}
async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):
async def save(self, model):
logger.debug(f"save: starting for uid={model['uid']}, is_final={model['is_final']}")
context = {}
context.update(model.record)
user = await self.app.services.user.get(model["user_uid"])
if not user:
logger.error(f"save: user not found user_uid={model['user_uid']}")
return False
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
context = json.loads(json.dumps(context, default=str))
loop = asyncio.get_event_loop()
logger.debug(f"save: rendering html for uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context)
result = await super().save(model)
if result:
logger.debug(f"save: message saved successfully uid={model['uid']}")
else:
logger.warning(f"save: failed to save message uid={model['uid']}")
if model['is_final']:
self.delete_executor(model['uid'])
return result
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return []
history_start_filter = ""
if channel["history_start"]:
history_start_filter = f" AND created_at > '{channel['history_start']}'"
results = []
offset = page * page_size
offset = page * page_size
try:
if timestamp:
async for model in self.query("SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=timestamp)):
async for model in self.query(
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
"timestamp": timestamp,
},
):
results.append(model)
elif page > 0:
async for model in self.query("SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):
async for model in self.query(
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
"timestamp": timestamp,
},
):
results.append(model)
else:
async for model in self.query("SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):
else:
async for model in self.query(
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
},
):
results.append(model)
except:
pass
results.sort(key=lambda x: x['created_at'])
return results
except Exception as ex:
logger.error(f"offset query failed: {ex}")
results.sort(key=lambda x: x["created_at"])
return results
+108 -21
View File
@@ -1,31 +1,118 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class ChatService(BaseService):
async def send(self,user_uid, channel_uid, message):
channel_message = await self.services.channel_message.create(
channel_uid,
user_uid,
message
async def finalize(self, message_uid):
logger.info(f"finalize: starting for message_uid={message_uid}")
channel_message = await self.services.channel_message.get(uid=message_uid)
if not channel_message:
logger.warning(f"finalize: message not found uid={message_uid}")
return
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
logger.debug(f"finalize: message marked as final uid={message_uid}")
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
await self.services.socket.broadcast(
channel["uid"],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user["uid"],
"color": user["color"],
"channel_uid": channel["uid"],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": channel_message["is_final"],
},
)
logger.info(f"finalize: completed for message_uid={message_uid}, channel={channel['uid']}")
await self.app.create_task(
self.services.notification.create_channel_message(message_uid)
)
await self.app.create_task(
self.services.mention.create_mentions_for_message(
message_uid=channel_message["uid"],
channel_uid=channel_message["channel_uid"],
message_text=channel_message["message"],
author_user_uid=channel_message["user_uid"]
)
)
async def send(self, user_uid, channel_uid, message, is_final=True):
logger.info(f"send: user_uid={user_uid}, channel_uid={channel_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
logger.error(f"send: channel not found channel_uid={channel_uid}")
raise Exception("Channel not found.")
logger.debug(f"send: checking for existing non-final message in channel={channel_uid}")
channel_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False
)
if channel_message:
logger.debug(f"send: updating existing message uid={channel_message['uid']}")
channel_message["message"] = message
channel_message["is_final"] = is_final
if not channel_message["is_final"]:
async with self.app.no_save():
await self.services.channel_message.save(channel_message)
else:
await self.services.channel_message.save(channel_message)
else:
logger.debug(f"send: creating new message in channel={channel_uid}")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"]
logger.debug(f"send: message saved uid={channel_message_uid}")
user = await self.services.user.get(uid=user_uid)
await self.services.notification.create_channel_message(channel_message_uid)
sent_to_count = await self.services.socket.broadcast(channel_uid, dict(
message=channel_message["message"],
html=channel_message["html"],
user_uid=user_uid,
color=user['color'],
channel_uid=channel_uid,
created_at=channel_message["created_at"],
updated_at=None,
username=user['username'],
uid=channel_message['uid'],
user_nick=user['nick']
))
return sent_to_count
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"send: broadcasting message to channel={channel_uid}")
await self.services.socket.broadcast(
channel_uid,
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user_uid,
"color": user["color"],
"channel_uid": channel_uid,
"created_at": channel_message["created_at"],
"updated_at": None,
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": is_final,
},
)
logger.info(f"send: completed message_uid={channel_message_uid}, channel={channel_uid}, is_final={is_final}")
await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid)
)
if is_final:
await self.app.create_task(
self.services.mention.create_mentions_for_message(
message_uid=channel_message["uid"],
channel_uid=channel_uid,
message_text=channel_message["message"],
author_user_uid=user_uid
)
)
return channel_message
+120
View File
@@ -0,0 +1,120 @@
# retoor <retoor@molodetz.nl>
from snek.system.docker import ComposeFileManager
from snek.system.service import BaseService
class ContainerService(BaseService):
mapper_name = "container"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.compose_path = "snek-container-compose.yml"
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
self.event_listeners = {}
async def shutdown(self):
return await self.compose.shutdown()
async def add_event_listener(self, name, event,event_handler):
if not name in self.event_listeners:
self.event_listeners[name] = {}
if not event in self.event_listeners[name]:
self.event_listeners[name][event] = []
self.event_listeners[name][event].append(event_handler)
async def remove_event_listener(self, name, event, event_handler):
if name in self.event_listeners and event in self.event_listeners[name]:
try:
self.event_listeners[name][event].remove(event_handler)
except ValueError:
pass
async def container_event_handler(self, name, event, data):
event_listeners = self.event_listeners.get(name, {})
handlers = event_listeners.get(event, [])
for handler in handlers:
if not await handler(data):
handlers.remove(handler)
async def get_instances(self):
return list(self.compose.list_instances())
async def get_container_name(self, channel_uid):
if channel_uid.startswith("channel-"):
return channel_uid
return f"channel-{channel_uid}"
async def get(self,channel_uid):
return await self.compose.get_instance(await self.get_container_name(channel_uid))
async def stop(self, channel_uid):
return await self.compose.stop(await self.get_container_name(channel_uid))
async def start(self, channel_uid):
return await self.compose.start(await self.get_container_name(channel_uid))
async def maintenance(self):
async for channel in self.services.channel.find():
if not await self.get(channel["uid"]):
print("Creating container for channel", channel["uid"])
result = await self.create(channel_uid=channel["uid"])
print(result)
async def get_status(self, channel_uid):
return await self.compose.get_instance_status(await self.get_container_name(channel_uid))
async def write_stdin(self, channel_uid, data):
return await self.compose.write_stdin(await self.get_container_name(channel_uid), data)
async def create(
self,
channel_uid,
image="ubuntu:latest",
command=None,
cpus=1,
memory="1024m",
ports=None,
volumes=None,
):
name = await self.get_container_name(channel_uid)
test = await self.compose.get_instance(name)
if test:
return test
self.compose.create_instance(
name,
image,
command,
cpus,
memory,
ports,
[
"./"
+ str(await self.services.channel.get_home_folder(channel_uid))
+ ":"
+ "/root"
],
)
return await self.compose.get_instance(name)
async def create2(
self, id, name, status, resources=None, user_uid=None, path=None, readonly=False
):
model = await self.new()
model["id"] = id
model["name"] = name
model["status"] = status
if resources:
model["resources"] = resources
if user_uid:
model["user_uid"] = user_uid
if path:
model["path"] = path
model["readonly"] = readonly
if await super().save(model):
return model
raise Exception(f"Failed to create container: {model.errors}")
+68
View File
@@ -0,0 +1,68 @@
# retoor <retoor@molodetz.nl>
import dataset
from snek.system.service import BaseService
class DBService(BaseService):
async def get_db(self, user_uid):
home_folder = await self.app.services.user.get_home_folder(user_uid)
home_folder.mkdir(parents=True, exist_ok=True)
db_path = home_folder.joinpath("snek/user.db")
db_path.parent.mkdir(parents=True, exist_ok=True)
return dataset.connect("sqlite:///" + str(db_path))
async def insert(self, user_uid, table_name, values):
db = await self.get_db(user_uid)
return db[table_name].insert(values)
async def update(self, user_uid, table_name, values, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
if not values:
return False
return db[table_name].update(values, filters)
async def upsert(self, user_uid, table_name, values, keys):
db = await self.get_db(user_uid)
return db[table_name].upsert(values, keys)
async def find(self, user_uid, table_name, kwargs):
db = await self.get_db(user_uid)
kwargs["_limit"] = kwargs.get("_limit", 30)
return [dict(row) for row in db[table_name].find(**kwargs)]
async def get(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
try:
return dict(db[table_name].find_one(**filters))
except ValueError:
return None
async def delete(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return db[table_name].delete(**filters)
async def query(self, sql, values):
db = await self.app.db
return [dict(row) for row in db.query(sql, values or {})]
async def exists(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return bool(db[table_name].find_one(**filters))
async def count(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return db[table_name].count(**filters)
+148 -14
View File
@@ -1,21 +1,155 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
class DriveService(BaseService):
mapper_name = "drive"
async def get_by_user(self, user_uid):
drives = []
async for model in self.find(user_uid=user_uid):
drives.append(model)
return drives
EXTENSIONS_PICTURES = ["jpg", "jpeg", "png", "gif", "svg", "webp", "tiff"]
EXTENSIONS_VIDEOS = [
"mp4",
"m4v",
"mov",
"wmv",
"webm",
"mkv",
"mpg",
"mpeg",
"avi",
"ogv",
"ogg",
"flv",
"3gp",
"3g2",
]
EXTENSIONS_ARCHIVES = [
"zip",
"rar",
"7z",
"tar",
"tar.gz",
"tar.xz",
"tar.bz2",
"tar.lzma",
"tar.lz",
]
EXTENSIONS_AUDIO = [
"mp3",
"wav",
"ogg",
"flac",
"m4a",
"wma",
"aac",
"opus",
"aiff",
"au",
"mid",
"midi",
]
EXTENSIONS_DOCS = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"txt",
"md",
"json",
"csv",
"xml",
"html",
"css",
"js",
"py",
"sql",
"rs",
"toml",
"yml",
"yaml",
"ini",
"conf",
"config",
"log",
"csv",
"tsv",
"java",
"cs",
"csproj",
"scss",
"less",
"sass",
"json",
"lock",
"lock.json",
"jsonl",
]
async def get_or_create(self, user_uid):
drives = await self.get_by_user(user_uid=user_uid)
if len(drives) == 0:
model = await self.new()
model['user_uid'] = user_uid
await self.save(model)
return model
return drives[0]
async def get_drive_name_by_extension(self, extension):
if extension.startswith("."):
extension = extension[1:]
if extension in self.EXTENSIONS_PICTURES:
return "Pictures"
if extension in self.EXTENSIONS_VIDEOS:
return "Videos"
if extension in self.EXTENSIONS_ARCHIVES:
return "Archives"
if extension in self.EXTENSIONS_AUDIO:
return "Audio"
if extension in self.EXTENSIONS_DOCS:
return "Documents"
return "My Drive"
async def get_drive_by_extension(self, user_uid, extension):
name = await self.get_drive_name_by_extension(extension)
return await self.get_or_create(user_uid=user_uid, name=name)
async def get_by_user(self, user_uid, name=None):
kwargs = {"user_uid": user_uid}
async for model in self.find(**kwargs):
if not name:
yield model
elif model["name"] == name:
yield model
elif not model["name"] and name == "My Drive":
model["name"] = "My Drive"
await self.save(model)
yield model
async def get_or_create(self, user_uid, name=None, extensions=None):
kwargs = {"user_uid": user_uid}
if name:
kwargs["name"] = name
async for model in self.get_by_user(**kwargs):
return model
model = await self.new()
model["user_uid"] = user_uid
model["name"] = name
await self.save(model)
return model
async def prepare_default_drives(self):
async for drive_item in self.services.drive_item.find():
extension = drive_item.extension
drive = await self.get_drive_by_extension(drive_item["user_uid"], extension)
if not drive_item["drive_uid"] == drive["uid"]:
drive_item["drive_uid"] = drive["uid"]
await self.services.drive_item.save(drive_item)
async def prepare_default_drives_for_user(self, user_uid):
await self.get_or_create(user_uid=user_uid, name="My Drive")
await self.get_or_create(user_uid=user_uid, name="Shared Drive")
await self.get_or_create(user_uid=user_uid, name="Pictures")
await self.get_or_create(user_uid=user_uid, name="Videos")
await self.get_or_create(user_uid=user_uid, name="Archives")
await self.get_or_create(user_uid=user_uid, name="Documents")
async def prepare_all(self):
await self.prepare_default_drives()
async for user in self.services.user.find():
await self.prepare_default_drives_for_user(user["uid"])
+13 -9
View File
@@ -1,18 +1,22 @@
from snek.system.service import BaseService
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
class DriveItemService(BaseService):
mapper_name = "drive_item"
async def create(self, drive_uid, name, path, type_,size):
model = await self.new()
model['drive_uid'] = drive_uid
model['name'] = name
model['path'] = str(path)
model['file_type'] = type_
model['file_size'] = size
async def create(self, drive_uid, name, path, type_, size):
model = await self.new()
model["drive_uid"] = drive_uid
model["name"] = name
model["path"] = str(path)
model["extension"] = str(name).split(".")[-1]
model["file_type"] = type_
model["file_size"] = size
if await self.save(model):
return model
return model
errors = await model.errors
print("XXXXXXXXXX")
raise Exception(f"Failed to create drive item: {errors}.")
+310
View File
@@ -0,0 +1,310 @@
# retoor <retoor@molodetz.nl>
# services/forum.py
from snek.system.service import BaseService
import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
import asyncio
import inspect
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
"""
Base mix-in that gives a service `add_notification_listener`,
an internal `_dispatch_event` helper, and a public `notify` method.
"""
def get_timestamp(self):
return now()
def generate_uid(self):
return str(uuid.uuid4())
def __init__(self, *args, **kwargs) -> None:
# Map event name -> list of listener callables
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
super().__init__(*args, **kwargs)
def add_notification_listener(
self, event_name: str, listener: EventListener
) -> None:
"""
Register a callback to be fired when `event_name` happens.
Parameters
----------
event_name : str
The name of the domain event, e.g. "post_created".
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
Your handler; can be async or sync.
"""
if not callable(listener):
raise TypeError("listener must be callable")
self._listeners[event_name].append(listener)
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if inspect.iscoroutinefunction(listener):
await 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:
"""
Public method to trigger notification to all listeners of an event.
Parameters
----------
event_name : str
The name of the event to notify listeners about.
data : Any
The data to pass to the listeners.
"""
await self._dispatch_event(event_name, data)
class ForumService(BaseForumService):
mapper_name = "forum"
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
if not slug:
slug = self.generate_slug(name)
# Check if slug exists
existing = await self.get(slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
model = await self.new()
model["name"] = name
model["description"] = description
model["slug"] = slug
model["created_by_uid"] = created_by_uid
if icon:
model["icon"] = icon
if await self.save(model):
await self.notify("forum_created", model)
return model
raise Exception(f"Failed to create forum: {model.errors}")
def generate_slug(self, text):
# Convert to lowercase and replace spaces with hyphens
slug = text.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug
async def get_active_forums(self):
async for forum in self.find(is_active=True, order_by="position"):
yield forum
async def update_last_post(self, forum_uid, thread_uid):
forum = await self.get(uid=forum_uid)
if forum:
forum["last_post_at"] = self.get_timestamp()
forum["last_thread_uid"] = thread_uid
await self.save(forum)
# services/thread.py
class ThreadService(BaseForumService):
mapper_name = "thread"
async def create_thread(self, forum_uid, title, content, created_by_uid):
# Generate slug
slug = self.services.forum.generate_slug(title)
# Check if slug exists in this forum
existing = await self.get(forum_uid=forum_uid, slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
# Create thread
thread = await self.new()
thread["forum_uid"] = forum_uid
thread["title"] = title
thread["slug"] = slug
thread["created_by_uid"] = created_by_uid
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
if await self.save(thread):
# Create first post
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=forum_uid,
content=content,
created_by_uid=created_by_uid,
is_first_post=True
)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_thread_count()
await self.services.forum.update_last_post(forum_uid, thread["uid"])
await self.notify("thread_created", {
"thread": thread,
"forum_uid": forum_uid
})
return thread, post
raise Exception(f"Failed to create thread: {thread.errors}")
async def toggle_pin(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user["is_admin"]:
return None
thread["is_pinned"] = not thread["is_pinned"]
await self.save(thread)
return thread
async def toggle_lock(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin or thread creator
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
return None
thread["is_locked"] = not thread["is_locked"]
await self.save(thread)
return thread
# services/post.py
class PostService(BaseForumService):
mapper_name = "post"
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
# Check if thread is locked
thread = await self.services.thread.get(uid=thread_uid)
if thread["is_locked"] and not is_first_post:
raise Exception("Thread is locked")
post = await self.new()
post["thread_uid"] = thread_uid
post["forum_uid"] = forum_uid
post["content"] = content
post["created_by_uid"] = created_by_uid
post["is_first_post"] = is_first_post
if await self.save(post):
# Update thread counters
if not is_first_post:
thread["post_count"] += 1
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
await self.services.thread.save(thread)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_post_count()
await self.services.forum.update_last_post(forum_uid, thread_uid)
await self.notify("post_created", {
"post": post,
"thread_uid": thread_uid,
"forum_uid": forum_uid
})
return post
raise Exception(f"Failed to create post: {post.errors}")
async def edit_post(self, post_uid, content, user_uid):
post = await self.get(uid=post_uid)
if not post:
return None
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user["is_admin"]:
return None
post["content"] = content
post["edited_at"] = self.get_timestamp()
post["edited_by_uid"] = user_uid
if await self.save(post):
await self.notify("post_edited", post)
return post
return None
async def delete_post(self, post_uid, user_uid):
post = await self.get(uid=post_uid)
if not post:
return False
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user["is_admin"]:
return False
# Don't allow deleting first post
if post["is_first_post"]:
return False
post["deleted_at"] = self.get_timestamp()
if await self.save(post):
await self.notify("post_deleted", post)
return True
return False
# services/post_like.py
class PostLikeService(BaseForumService):
mapper_name = "post_like"
async def toggle_like(self, post_uid, user_uid):
# Check if already liked
existing = await self.get(post_uid=post_uid, user_uid=user_uid)
if existing:
# Unlike
await self.delete(uid=existing["uid"])
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] = max(0, post["like_count"] - 1)
await self.services.post.save(post)
await self.notify("post_unliked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return False
else:
# Like
like = await self.new()
like["post_uid"] = post_uid
like["user_uid"] = user_uid
if await self.save(like):
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] += 1
await self.services.post.save(post)
await self.notify("post_liked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return True
return None
+139
View File
@@ -0,0 +1,139 @@
# retoor <retoor@molodetz.nl>
import re
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class MentionService(BaseService):
mapper_name = "mention"
MENTION_REGEX = re.compile(r'@([a-zA-Z0-9_-]+)')
def extract_mentions(self, text):
if not text:
return []
return self.MENTION_REGEX.findall(text)
async def resolve_mention_to_user(self, mention_text):
if not mention_text:
return None
user = await self.services.user.get(username=mention_text)
if user:
return user
async for u in self.services.user.find(nick=mention_text, _limit=1):
return u
return None
async def create_mentions_for_message(self, message_uid, channel_uid, message_text, author_user_uid):
mentions = self.extract_mentions(message_text)
if not mentions:
return []
created_mentions = []
seen_user_uids = set()
for mention_text in mentions:
user = await self.resolve_mention_to_user(mention_text)
if not user:
continue
if user["uid"] == author_user_uid:
continue
if user["uid"] in seen_user_uids:
continue
seen_user_uids.add(user["uid"])
model = await self.new()
model["message_uid"] = message_uid
model["channel_uid"] = channel_uid
model["mentioned_user_uid"] = user["uid"]
model["author_user_uid"] = author_user_uid
try:
if await self.save(model):
created_mentions.append(model)
await self.services.socket.send_to_user(
user["uid"],
{
"event": "new_mention",
"data": {
"mention_uid": model["uid"],
"message_uid": message_uid,
"channel_uid": channel_uid,
"author_user_uid": author_user_uid
}
}
)
except Exception as ex:
logger.warning(f"Failed to create mention: {ex}")
return created_mentions
async def get_unread_mentions(self, user_uid, channel_uid=None):
kwargs = {
"mentioned_user_uid": user_uid,
"read_at": None,
"deleted_at": None
}
if channel_uid:
kwargs["channel_uid"] = channel_uid
results = []
async for mention in self.find(**kwargs):
results.append(mention)
return results
async def get_unread_count(self, user_uid, channel_uid=None):
kwargs = {
"mentioned_user_uid": user_uid,
"read_at": None,
"deleted_at": None
}
if channel_uid:
kwargs["channel_uid"] = channel_uid
return await self.count(**kwargs)
async def mark_read(self, mention_uid, user_uid):
mention = await self.get(uid=mention_uid)
if not mention:
return False
if mention["mentioned_user_uid"] != user_uid:
return False
mention["read_at"] = now()
return await self.save(mention)
async def mark_all_read_in_channel(self, user_uid, channel_uid):
count = 0
async for mention in self.find(
mentioned_user_uid=user_uid,
channel_uid=channel_uid,
read_at=None,
deleted_at=None
):
mention["read_at"] = now()
try:
if await self.save(mention):
count += 1
except Exception as ex:
logger.warning(f"Failed to mark mention as read: {ex}")
return count
async def get_oldest_unread_mention(self, user_uid, channel_uid):
sql = """
SELECT m.* FROM mention m
JOIN channel_message cm ON m.message_uid = cm.uid
WHERE m.mentioned_user_uid = :user_uid
AND m.channel_uid = :channel_uid
AND m.read_at IS NULL
AND m.deleted_at IS NULL
AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT 1
"""
async for record in self.query(sql, {"user_uid": user_uid, "channel_uid": channel_uid}):
return record
return None
+56 -3
View File
@@ -1,9 +1,31 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.markdown import strip_markdown
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class NotificationService(BaseService):
mapper_name = "notification"
async def mark_as_read(self, user_uid, channel_message_uid):
model = await self.get(user_uid, object_uid=channel_message_uid)
if not model:
return False
model["read_at"] = now()
await self.save(model)
return True
async def get_unread_stats(self, user_uid):
await self.query(
"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type",
{"user_uid": user_uid},
)
async def create(self, object_uid, object_type, user_uid, message):
model = await self.new()
model["object_uid"] = object_uid
@@ -18,13 +40,25 @@ class NotificationService(BaseService):
channel_message = await self.services.channel_message.get(
uid=channel_message_uid
)
if not channel_message["is_final"]:
return
user = await self.services.user.get(uid=channel_message["user_uid"])
self.app.db.begin()
async for channel_member in self.services.channel_member.find(
channel_uid=channel_message["channel_uid"],
is_banned=False,
is_muted=False,
deleted_at=None,
):
if not channel_member["new_count"]:
channel_member["new_count"] = 0
channel_member["new_count"] += 1
usr = await self.services.user.get(uid=channel_member["user_uid"])
if not usr:
continue
await self.services.channel_member.save(channel_member)
model = await self.new()
model["object_uid"] = channel_message_uid
model["object_type"] = "channel_message"
@@ -32,6 +66,25 @@ class NotificationService(BaseService):
model["message"] = (
f"New message from {user['nick']} in {channel_member['label']}."
)
if await self.save(model):
return model
raise Exception(f"Failed to create notification: {model.errors}.")
try:
await self.save(model)
except Exception:
raise Exception(f"Failed to create notification: {model.errors}.")
if channel_member["user_uid"] != user["uid"]:
try:
stripped_message = strip_markdown(channel_message["message"])
channel_name = await channel_member.get_name()
await self.app.services.push.notify_user(
user_uid=channel_member["user_uid"],
payload={
"title": f"New message in {channel_name}",
"message": f"{user['nick']}: {stripped_message}",
"icon": "/image/snek192.png",
"url": f"/channel/{channel_message['channel_uid']}.html",
},
)
except Exception as e:
logger.warning(f"Failed to send push notification: {e}")
self.app.db.commit()
+87
View File
@@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from typing import Optional, List
from snek.system.service import BaseService
from snek.system.exceptions import ValidationError, DuplicateResourceError
logger = logging.getLogger(__name__)
class ProfilePageService(BaseService):
mapper_name = "profile_page"
def slugify(self, title: str) -> str:
slug = title.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug[:100]
async def create_page(self, user_uid: str, title: str, content: str = "", is_published: bool = True) -> dict:
slug = self.slugify(title)
existing = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if existing:
raise DuplicateResourceError(f"A page with slug '{slug}' already exists")
pages = [p async for p in self.find(user_uid=user_uid, deleted_at=None)]
max_order = max([p["order_index"] for p in pages], default=-1)
model = await self.new()
model["user_uid"] = user_uid
model["title"] = title
model["slug"] = slug
model["content"] = content
model["order_index"] = max_order + 1
model["is_published"] = is_published
await self.save(model)
return model
async def update_page(self, page_uid: str, title: Optional[str] = None,
content: Optional[str] = None, is_published: Optional[bool] = None) -> dict:
page = await self.get(uid=page_uid, deleted_at=None)
if not page:
raise ValidationError("Page not found")
if title is not None:
page["title"] = title
new_slug = self.slugify(title)
existing = await self.get(user_uid=page["user_uid"], slug=new_slug, deleted_at=None)
if existing and existing["uid"] != page_uid:
raise DuplicateResourceError(f"A page with slug '{new_slug}' already exists")
page["slug"] = new_slug
if content is not None:
page["content"] = content
if is_published is not None:
page["is_published"] = is_published
return await self.save(page)
async def get_user_pages(self, user_uid: str, include_unpublished: bool = False) -> List[dict]:
if include_unpublished:
pages = [p.record async for p in self.find(user_uid=user_uid, deleted_at=None)]
else:
pages = [p.record async for p in self.find(user_uid=user_uid, is_published=True, deleted_at=None)]
return sorted(pages, key=lambda p: p["order_index"])
async def get_page_by_slug(self, user_uid: str, slug: str, include_unpublished: bool = False) -> Optional[dict]:
page = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if page and (include_unpublished or page["is_published"]):
return page
return None
async def reorder_pages(self, user_uid: str, page_uids: List[str]) -> None:
for index, page_uid in enumerate(page_uids):
page = await self.get(uid=page_uid, user_uid=user_uid, deleted_at=None)
if page:
page["order_index"] = index
await self.save(page)
async def delete_page(self, page_uid: str) -> None:
page = await self.get(uid=page_uid, deleted_at=None)
if page:
await self.delete(page)
+269
View File
@@ -0,0 +1,269 @@
# retoor <retoor@molodetz.nl>
import base64
import json
import os.path
import random
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from snek.system.service import BaseService
# The only reason to persist the keys is to be able to use them in the web push
PRIVATE_KEY_FILE = Path("./notification-private.pem")
PRIVATE_KEY_PKCS8_FILE = Path("./notification-private.pkcs8.pem")
PUBLIC_KEY_FILE = Path("./notification-public.pem")
def generate_private_key():
if not PRIVATE_KEY_FILE.exists():
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
PRIVATE_KEY_FILE.write_bytes(pem)
def generate_pcks8_private_key():
if not PRIVATE_KEY_PKCS8_FILE.exists():
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
PRIVATE_KEY_PKCS8_FILE.write_bytes(pem)
def generate_public_key():
if not PUBLIC_KEY_FILE.exists():
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
public_key = private_key.public_key()
pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
PUBLIC_KEY_FILE.write_bytes(pem)
def ensure_certificates():
generate_private_key()
generate_pcks8_private_key()
generate_public_key()
def hkdf(input_key, salt, info, length):
return HKDF(
algorithm=SHA256(),
length=length,
salt=salt,
info=info,
backend=default_backend(),
).derive(input_key)
def _browser_base64(data):
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
class PushService(BaseService):
mapper_name = "push"
private_key_pem = None
public_key = None
public_key_base64 = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ensure_certificates()
private_key = serialization.load_pem_private_key(
PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
self.private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
self.public_key = serialization.load_pem_public_key(
PUBLIC_KEY_FILE.read_bytes(), backend=default_backend()
)
self.public_key_base64 = _browser_base64(
self.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
def create_notification_authorization(self, push_url):
target = urlparse(push_url)
aud = f"{target.scheme}://{target.netloc}"
sub = "mailto:admin@molodetz.nl"
identifier = str(uuid.uuid4())
print(
f"Creating notification authorization for {aud} with identifier {identifier}"
)
return jwt.encode(
{
"sub": sub,
"aud": aud,
"exp": int(time.time()) + 60 * 60,
"nbf": int(time.time()),
"iat": int(time.time()),
"jti": identifier,
},
self.private_key_pem,
algorithm="ES256",
)
def create_notification_info_with_payload(
self, endpoint: str, auth: str, p256dh: str, payload: str
):
message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
message_public_key_bytes = message_private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
salt = os.urandom(16)
user_key_bytes = base64.urlsafe_b64decode(p256dh + "==")
shared_secret = message_private_key.exchange(
ec.ECDH(),
ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), user_key_bytes
),
)
encryption_key = hkdf(
shared_secret,
base64.urlsafe_b64decode(auth + "=="),
b"Content-Encoding: auth\x00",
32,
)
context = (
b"P-256\x00"
+ len(user_key_bytes).to_bytes(2, "big")
+ user_key_bytes
+ len(message_public_key_bytes).to_bytes(2, "big")
+ message_public_key_bytes
)
nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12)
content_encryption_key = hkdf(
encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16
)
padding_length = random.randint(0, 16)
padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length
data = AESGCM(content_encryption_key).encrypt(
nonce, padding + payload.encode("utf-8"), None
)
return {
"headers": {
"Authorization": f"WebPush {self.create_notification_authorization(endpoint)}",
"Crypto-Key": f"dh={_browser_base64(message_public_key_bytes)}; p256ecdsa={self.public_key_base64}",
"Encryption": f"salt={_browser_base64(salt)}",
"Content-Encoding": "aesgcm",
"Content-Length": str(len(data)),
"Content-Type": "application/octet-stream",
},
"data": data,
}
async def notify_user(self, user_uid: str, payload: dict):
async with aiohttp.ClientSession() as session:
async for subscription in self.find(user_uid=user_uid):
endpoint = subscription["endpoint"]
key_auth = subscription["key_auth"]
key_p256dh = subscription["key_p256dh"]
notification_info = self.create_notification_info_with_payload(
endpoint, key_auth, key_p256dh, json.dumps(payload)
)
headers = {
**notification_info["headers"],
"TTL": "60",
}
data = notification_info["data"]
async with session.post(
endpoint,
headers=headers,
data=data,
) as response:
if response.status == 201 or response.status == 200:
print(
f"Notification sent to user {user_uid} via endpoint {endpoint}"
)
else:
print(
f"Failed to send notification to user {user_uid} via endpoint {endpoint}: {response.status}"
)
async def register(
self, user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
):
if await self.exists(
user_uid=user_uid,
endpoint=endpoint,
key_auth=key_auth,
key_p256dh=key_p256dh,
):
return
model = await self.new()
model["user_uid"] = user_uid
model["endpoint"] = endpoint
model["key_auth"] = key_auth
model["key_p256dh"] = key_p256dh
print(
f"Registering push subscription for user {user_uid} with endpoint {endpoint}"
)
if await self.save(model=model) and model:
print(
f"Push subscription registered for user {user_uid} with endpoint {endpoint}"
)
return model
raise Exception(
f"Failed to register push subscription for user {user_uid} with endpoint {endpoint}"
)
+65
View File
@@ -0,0 +1,65 @@
# retoor <retoor@molodetz.nl>
import asyncio
import shutil
from snek.system.service import BaseService
class RepositoryService(BaseService):
mapper_name = "repository"
async def delete(self, user_uid, name):
loop = asyncio.get_event_loop()
repository_path = (
await self.services.user.get_repository_path(user_uid)
).joinpath(name + ".git")
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
print(ex)
await super().delete(user_uid=user_uid, name=name)
async def exists(self, user_uid, name, **kwargs):
kwargs["user_uid"] = user_uid
kwargs["name"] = name
return await super().exists(**kwargs)
async def init(self, user_uid, name):
repository_path = await self.services.user.get_repository_path(user_uid)
if not repository_path.exists():
repository_path.mkdir(parents=True)
repository_path = repository_path.joinpath(name)
repository_path = str(repository_path)
if not repository_path.endswith(".git"):
repository_path += ".git"
command = ["git", "init", "--bare", repository_path]
process = await asyncio.subprocess.create_subprocess_exec(
*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
return process.returncode == 0
async def create(self, user_uid, name, is_private=False, description=None):
if await self.exists(user_uid=user_uid, name=name):
return False
if not await self.init(user_uid=user_uid, name=name):
return False
model = await self.new()
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)
+341 -40
View File
@@ -1,7 +1,36 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
from snek.system.model import now
logger = logging.getLogger(__name__)
def safe_get(obj, key, default=None):
if obj is None:
return default
try:
if isinstance(obj, dict):
return obj.get(key, default)
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
val = obj[key]
return val if val is not None else default
return getattr(obj, key, default)
except (KeyError, TypeError, AttributeError):
return default
def safe_str(obj):
if obj is None:
return ""
try:
return str(obj)
except Exception:
return ""
class SocketService(BaseService):
@@ -9,65 +38,337 @@ class SocketService(BaseService):
class Socket:
def __init__(self, ws, user: UserModel):
self.ws = ws
self.is_connected = True
self.user = user
self.is_connected = True
self.user = user
self.user_uid = safe_get(user, "uid") if user else None
self.user_color = safe_get(user, "color") if user else None
self.subscribed_channels = set()
async def send_json(self, data):
if data is None:
return False
if not self.is_connected:
return False
return False
if not self.ws:
self.is_connected = False
return False
try:
await self.ws.send_json(data)
except Exception as ex:
print(ex,flush=True)
return True
except ConnectionResetError:
self.is_connected = False
return True
logger.debug("Connection reset during send_json")
except Exception as ex:
self.is_connected = False
logger.debug(f"send_json failed: {safe_str(ex)}")
return False
async def close(self):
if not self.is_connected:
return True
await self.ws.close()
return True
self.is_connected = False
return True
self.subscribed_channels.clear()
try:
if self.ws and not self.ws.closed:
await asyncio.wait_for(self.ws.close(), timeout=300.0)
except asyncio.TimeoutError:
logger.debug("Socket close timed out")
except Exception as ex:
logger.debug(f"Socket close failed: {safe_str(ex)}")
return True
def __init__(self, app):
super().__init__(app)
self.sockets = set()
self.users = {}
self.subscriptions = {}
self.last_update = str(datetime.now())
self._lock = asyncio.Lock()
async def user_availability_service(self):
logger.info("User availability update service started.")
while True:
try:
sockets_copy = list(self.sockets)
users_to_update = []
seen_user_uids = set()
for s in sockets_copy:
try:
if not s or not s.is_connected:
continue
if not s.user or not s.user_uid:
continue
if s.user_uid in seen_user_uids:
continue
seen_user_uids.add(s.user_uid)
users_to_update.append(s.user_uid)
except Exception as ex:
logger.debug(f"Failed to check user availability: {safe_str(ex)}")
for user_uid in users_to_update:
try:
if self.app and hasattr(self.app, "services") and self.app.services:
user = await self.app.services.user.get(uid=user_uid)
if user:
user["last_ping"] = now()
await self.app.services.user.save(user)
except Exception as ex:
logger.debug(f"Failed to update user availability: {safe_str(ex)}")
logger.info(f"Updated user availability for {len(users_to_update)} online users.")
except Exception as ex:
logger.warning(f"User availability service error: {safe_str(ex)}")
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("User availability service cancelled")
break
async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
if not self.users.get(user_uid):
self.users[user_uid] = set()
self.users[user_uid].add(s)
if not ws:
return None
if not user_uid:
return None
try:
if not self.app or not hasattr(self.app, "services") or not self.app.services:
logger.warning("Services not available for socket add")
return None
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.warning(f"User not found for socket add: {user_uid}")
return None
s = self.Socket(ws, user)
username = safe_get(user, "username", "unknown")
nick = safe_get(user, "nick") or username
color = s.user_color
async with self._lock:
self.sockets.add(s)
is_first_connection = False
if user_uid not in self.users:
self.users[user_uid] = set()
is_first_connection = True
elif len(self.users[user_uid]) == 0:
is_first_connection = True
self.users[user_uid].add(s)
try:
fresh_user = await self.app.services.user.get(uid=user_uid)
if fresh_user:
fresh_user["last_ping"] = now()
await self.app.services.user.save(fresh_user)
except Exception as ex:
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
logger.info(f"Added socket for user {username}")
if is_first_connection:
await self._broadcast_presence("arrived", user_uid, nick, color)
return s
except Exception as ex:
logger.warning(f"Failed to add socket: {safe_str(ex)}")
return None
async def subscribe(self, ws,channel_uid, user_uid):
return
if not channel_uid in self.subscriptions:
self.subscriptions[channel_uid] = set()
s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))
self.subscriptions[channel_uid].add(s)
async def subscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
existing_socket = None
user_sockets = self.users.get(user_uid, set())
for sock in user_sockets:
if sock and sock.ws == ws:
existing_socket = sock
break
if not existing_socket:
return False
existing_socket.subscribed_channels.add(channel_uid)
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(user_uid)
return True
except Exception as ex:
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
return False
async def unsubscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
for s in self.sockets:
if s and s.ws == ws:
s.subscribed_channels.discard(channel_uid)
break
return True
except Exception as ex:
logger.warning(f"Failed to unsubscribe: {safe_str(ex)}")
return False
async def send_to_user(self, user_uid, message):
count = 0
for s in self.users.get(user_uid,[]):
if await s.send_json(message):
count += 1
return count
if not user_uid or message is None:
return 0
count = 0
try:
user_sockets = list(self.users.get(user_uid, []))
for s in user_sockets:
if not s:
continue
try:
if await s.send_json(message):
count += 1
except Exception as ex:
logger.debug(f"Failed to send to user socket: {safe_str(ex)}")
except Exception as ex:
logger.warning(f"send_to_user failed: {safe_str(ex)}")
return count
async def broadcast(self, channel_uid, message):
count = 0
async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):
count += await self.send_to_user(channel_member["user_uid"],message)
return count
if not channel_uid or message is None:
logger.debug(f"broadcast: invalid params channel_uid={channel_uid}, message={message is not None}")
return False
logger.debug(f"broadcast: starting for channel={channel_uid}")
return await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
if not channel_uid or message is None:
return False
sent = 0
user_uids_to_send = set()
try:
if self.services:
try:
async for user_uid in self.services.channel_member.get_user_uids(channel_uid):
if user_uid:
user_uids_to_send.add(user_uid)
logger.debug(f"_broadcast: found {len(user_uids_to_send)} users from db for channel={channel_uid}")
except Exception as ex:
logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
if not user_uids_to_send:
if channel_uid in self.subscriptions:
user_uids_to_send = set(self.subscriptions[channel_uid])
logger.debug(f"_broadcast: using {len(user_uids_to_send)} users from subscriptions for channel={channel_uid}")
send_tasks = []
for user_uid in user_uids_to_send:
send_tasks.append(self._send_to_user_safe(user_uid, message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
for result in results:
if isinstance(result, int):
sent += result
logger.info(f"_broadcast: completed channel={channel_uid}, total_users={len(user_uids_to_send)}, sent={sent}")
return True
except Exception as ex:
logger.warning(f"Broadcast failed: {safe_str(ex)}")
return False
async def _send_to_user_safe(self, user_uid, message):
try:
return await self.send_to_user(user_uid, message)
except Exception as ex:
logger.debug(f"Failed to send to user {user_uid}: {safe_str(ex)}")
return 0
async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close()
self.sockets.remove(s)
if not ws:
return
async with self._lock:
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
for s in sockets_to_remove:
self.sockets.discard(s)
departures_to_broadcast = []
channels_to_cleanup = set()
for s in sockets_to_remove:
user_uid = s.user_uid
if not user_uid:
continue
is_last_connection = False
if user_uid in self.users:
self.users[user_uid].discard(s)
if len(self.users[user_uid]) == 0:
del self.users[user_uid]
is_last_connection = True
if is_last_connection:
user_nick = None
try:
if s.user:
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
except Exception:
pass
if user_nick:
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
for channel_uid in list(s.subscribed_channels):
channels_to_cleanup.add((channel_uid, user_uid))
for channel_uid, user_uid in channels_to_cleanup:
try:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
except Exception as ex:
logger.debug(f"Failed to cleanup channel subscription: {safe_str(ex)}")
for s in sockets_to_remove:
try:
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
logger.info(f"Removed socket for user {username}")
await s.close()
except Exception as ex:
logger.warning(f"Socket close failed: {safe_str(ex)}")
for user_uid, user_nick, user_color in departures_to_broadcast:
try:
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
except Exception as ex:
logger.debug(f"Failed to broadcast departure: {safe_str(ex)}")
async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color):
if not user_uid or not user_nick:
return
if not event_type or event_type not in ("arrived", "departed"):
return
try:
message = {
"event": "user_presence",
"data": {
"type": event_type,
"user_uid": user_uid,
"user_nick": user_nick,
"user_color": user_color,
"timestamp": datetime.now().isoformat(),
},
}
sockets_copy = list(self.sockets)
send_tasks = []
for s in sockets_copy:
if not s or not s.is_connected:
continue
if s.user_uid == user_uid:
continue
send_tasks.append(s.send_json(message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
sent_count = sum(1 for r in results if r is True)
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
except Exception as ex:
logger.warning(f"Broadcast presence failed: {safe_str(ex)}")
async def get_connected_users(self):
try:
return list(self.users.keys())
except Exception:
return []
async def get_user_socket_count(self, user_uid):
if not user_uid:
return 0
try:
return len(self.users.get(user_uid, []))
except Exception:
return 0
async def is_user_online(self, user_uid):
if not user_uid:
return False
try:
user_sockets = self.users.get(user_uid, set())
return any(s.is_connected for s in user_sockets if s)
except Exception:
return False
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
import sqlite3
from snek.system.service import BaseService
class StatisticsService(BaseService):
def database(self):
db_path = self.app.db_path.split("///")[-1]
print(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Existing analysis code...
def get_table_columns(table_name):
cursor.execute(f"PRAGMA table_info({table_name})")
return cursor.fetchall()
tables = [
'http_access', 'user', 'channel', 'channel_member', 'broadcast',
'channel_message', 'notification', 'repository', 'test', 'drive',
'user_property', 'a', 'channel_attachment', 'push_registration'
]
for table in tables:
print(f"\n--- Statistics for table: {table} ---")
columns = get_table_columns(table)
cursor.execute(f"SELECT COUNT(*) FROM {table}")
total_rows = cursor.fetchone()[0]
print(f"Total rows: {total_rows}")
for col in columns:
cid, name, col_type, notnull, dflt_value, pk = col
col_type_upper = col_type.upper()
cursor.execute(f"SELECT COUNT(DISTINCT '{name}') FROM {table}")
distinct_count = cursor.fetchone()[0]
print(f"\nColumn: {name} ({col_type})")
print(f"Distinct values: {distinct_count}")
if 'INT' in col_type_upper or 'BIGINT' in col_type_upper or 'FLOAT' in col_type_upper:
cursor.execute(f"SELECT MIN('{name}'), MAX('{name}'), AVG('{name}') FROM {table} WHERE '{name}' IS NOT NULL")
min_val, max_val, avg_val = cursor.fetchone()
print(f"Min: {min_val}, Max: {max_val}, Avg: {avg_val}")
elif 'TEXT' in col_type_upper and ('date' in name.lower() or 'time' in name.lower() or 'created' in name.lower() or 'updated' in name.lower() or 'on' in name.lower()):
cursor.execute(f"SELECT MIN({name}), MAX({name}) FROM {table} WHERE {name} IS NOT NULL")
min_date, max_date = cursor.fetchone()
print(f"Earliest: {min_date}, Latest: {max_date}")
elif 'TEXT' in col_type_upper:
cursor.execute(f"SELECT LENGTH({name}) FROM {table} WHERE {name} IS NOT NULL")
lengths = [len_row[0] for len_row in cursor.fetchall()]
if lengths:
avg_length = sum(lengths) / len(lengths)
max_length = max(lengths)
min_length = min(lengths)
print(f"Avg length: {avg_length:.2f}, Max length: {max_length}, Min length: {min_length}")
else:
print("No data to compute length statistics.")
# New statistics functions
def get_time_series_stats(table_name, date_column):
cursor.execute(f"SELECT strftime('%Y-%m-%d', {date_column}) AS day, COUNT(*) FROM {table_name} GROUP BY day")
return cursor.fetchall()
def get_count_created(table_name, date_column):
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
return cursor.fetchone()[0]
def get_channels_per_user():
cursor.execute("SELECT user_uid, COUNT(*) AS channel_count FROM channel_member GROUP BY user_uid ORDER BY channel_count DESC")
return cursor.fetchall()
def get_online_users():
cursor.execute("SELECT COUNT(*) FROM user WHERE last_ping >= datetime('now', '-5 minutes')")
return cursor.fetchone()[0]
# Example usage of new functions
messages_per_day = get_time_series_stats('channel_message', 'created_at')
users_created = get_count_created('user', 'created_at')
channels_created = get_count_created('channel', 'created_at')
channels_per_user = get_channels_per_user()
online_users = get_online_users()
# Print or store these stats as needed
print("\nMessages per day:", messages_per_day)
print("Total users created:", users_created)
print("Total channels created:", channels_created)
print("Channels per user (top):", channels_per_user[:10])
print("Currently online users:", online_users)
conn.close()
+73 -6
View File
@@ -1,16 +1,24 @@
# retoor <retoor@molodetz.nl>
import pathlib
from snek.system import security
from snek.system.service import BaseService
class UserService(BaseService):
mapper_name = "user"
async def get_by_username(self, username):
return await self.get(username=username)
async def search(self, query, **kwargs):
query = query.strip().lower()
kwargs["deleted_at"] = None
if not query:
raise []
return []
results = []
async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
return results
@@ -23,20 +31,79 @@ class UserService(BaseService):
return True
async def save(self, user):
if not user['color']:
user['color'] = await self.services.util.random_light_hex_color()
if not user["color"]:
user["color"] = await self.services.util.random_light_hex_color()
return await super().save(user)
def authenticate_sync(self, username, password):
user = self.get_by_username_sync(username)
if not user:
return False
if not security.verify_sync(password, user["password"]):
return False
return True
async def authenticate(self, username, password):
success = await self.validate_login(username, password)
if not success:
return None
model = await self.get(username=username, deleted_at=None)
return model
def get_admin_uids(self):
return self.mapper.get_admin_uids()
async def get_repository_path(self, user_uid):
return pathlib.Path(f"./drive/repositories/{user_uid}")
async def get_static_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/static")
if not path.exists():
return None
return path
async def get_template_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
if not path.exists():
return None
return path
def get_by_username_sync(self, username):
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
return dict(user)
def get_home_folder_by_username(self, username):
user = self.get_by_username_sync(username)
folder = pathlib.Path(f"./drive/{user['uid']}")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return folder
async def get_home_folder(self, user_uid):
folder = pathlib.Path(f"./drive/{user_uid}")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return folder
async def register(self, email, username, password):
if await self.exists(username=username):
raise Exception("User already exists.")
model = await self.new()
model["nick"] = username
model['color'] = await self.services.util.random_light_hex_color()
model["color"] = await self.services.util.random_light_hex_color()
model.email.value = email
model.username.value = username
model.password.value = await security.hash(password)
if not await self.count():
model["is_admin"] = True
if await self.save(model):
if model:
channel = await self.services.channel.ensure_public_channel(
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
import json
from snek.system.service import BaseService
class UserPropertyService(BaseService):
mapper_name = "user_property"
async def set(self, user_uid, name, value):
self.mapper.db["user_property"].upsert(
{
"user_uid": user_uid,
"name": name,
"value": json.dumps(value, default=str),
},
["user_uid", "name"],
)
self.mapper.db.commit()
async def get(self, user_uid, name):
try:
return json.loads(
(await super().get(user_uid=user_uid, name=name))["value"]
)
except Exception as ex:
print(ex)
return None
async def search(self, query, **kwargs):
query = query.strip().lower()
if not query:
raise []
results = []
async for result in self.find(name={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
return results
+6 -5
View File
@@ -1,15 +1,16 @@
import random
# retoor <retoor@molodetz.nl>
import random
from snek.system.service import BaseService
class UtilService(BaseService):
async def random_light_hex_color(self):
r = random.randint(128, 255)
g = random.randint(128, 255)
b = random.randint(128, 255)
return "#{:02x}{:02x}{:02x}".format(r, g, b)
return f"#{r:02x}{g:02x}{b:02x}"
+671
View File
@@ -0,0 +1,671 @@
# retoor <retoor@molodetz.nl>
import asyncio
import base64
import json
import logging
import os
import shutil
import tempfile
from aiohttp import web
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("git_server")
class GitApplication(web.Application):
def __init__(self, parent=None):
import git
globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes(
[
web.post("/{username}/{repo_name}/create", self.create_repository),
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
web.get("/{username}/{repo_name}/clone", self.clone_repository),
web.post("/{username}/{repo_name}/push", self.push_repository),
web.post("/{username}/{repo_name}/pull", self.pull_repository),
web.get("/{username}/{repo_name}/status", self.status_repository),
web.get("/{username}/{repo_name}/branches", self.list_branches),
web.post("/{username}/{repo_name}/branches", self.create_branch),
web.get("/{username}/{repo_name}/log", self.commit_log),
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
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["auth_user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
if not request["auth_user"]:
return None, None, None
path_username = request.match_info.get("username")
if not path_username:
return None, None, None
if path_username.count("-") == 4:
target_user = await self.parent.services.user.get(uid=path_username)
else:
target_user = await self.parent.services.user.get(username=path_username)
if not target_user:
return None, None, None
request["target_user"] = target_user
request["repository_path"] = (
await self.parent.services.user.get_repository_path(target_user["uid"])
)
return request["auth_user"]["username"], target_user, request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, target_user, repository_path = await self.check_basic_auth(request)
if not username or not target_user or not repository_path:
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)
return wrapped
def repo_path(self, repository_path, repo_name):
return repository_path.joinpath(repo_name + ".git")
def check_repo_exists(self, repository_path, repo_name):
repo_dir = self.repo_path(repository_path, repo_name)
if not os.path.exists(repo_dir):
return web.Response(text="Repository not found", status=404)
return None
@require_auth
async def create_repository(self, request):
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)
if os.path.exists(repo_dir):
return web.Response(text="Repository already exists", status=400)
try:
git.Repo.init(repo_dir, bare=True)
logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Created repository {repo_name}")
except Exception as e:
logger.error(f"Error creating repository {repo_name}: {str(e)}")
return web.Response(text=f"Error creating repository: {str(e)}", status=500)
@require_auth
async def delete_repository(self, request):
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 {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)}")
return web.Response(text=f"Error deleting repository: {str(e)}", status=500)
@require_auth
async def clone_repository(self, request):
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
host = request.host
clone_url = f"http://{host}/{repo_name}.git"
response_data = {
"repository": repo_name,
"clone_command": f"git clone {clone_url}",
"clone_url": clone_url,
}
return web.json_response(response_data)
@require_auth
async def push_repository(self, request):
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
try:
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
commit_message = data.get("commit_message", "Update from server")
branch = data.get("branch", "main")
changes = data.get("changes", [])
if not changes:
return web.Response(text="No changes provided", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
for change in changes:
file_path = os.path.join(temp_dir, change.get("file", ""))
content = change.get("content", "")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f:
f.write(content)
temp_repo.git.add(A=True)
if not temp_repo.config_reader().has_section("user"):
temp_repo.config_writer().set_value(
"user", "name", "Git Server"
).release()
temp_repo.config_writer().set_value(
"user", "email", "git@server.local"
).release()
temp_repo.index.commit(commit_message)
origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
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
try:
data = await request.json()
except json.JSONDecodeError:
data = {}
remote_url = data.get("remote_url")
branch = data.get("branch", "main")
if not remote_url:
return web.Response(text="Remote URL is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
local_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
remote_name = "pull_source"
try:
remote = local_repo.create_remote(remote_name, remote_url)
except git.GitCommandError:
remote = local_repo.remote(remote_name)
remote.set_url(remote_url)
remote.fetch()
local_repo.git.merge(f"{remote_name}/{branch}")
origin = local_repo.remote("origin")
origin.push()
logger.info(
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}"
)
except Exception as e:
logger.error(f"Error pulling to {repo_name}: {str(e)}")
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
@require_auth
async def status_repository(self, request):
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
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
branches = [b.name for b in temp_repo.branches]
active_branch = temp_repo.active_branch.name
commits = []
for commit in list(temp_repo.iter_commits(max_count=5)):
commits.append(
{
"id": commit.hexsha,
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message,
}
)
files = []
for root, dirs, filenames in os.walk(temp_dir):
if ".git" in root:
continue
for filename in filenames:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, temp_dir)
files.append(rel_path)
status_info = {
"repository": repo_name,
"branches": branches,
"active_branch": active_branch,
"recent_commits": commits,
"files": files,
}
return web.json_response(status_info)
except Exception as e:
logger.error(f"Error getting status for {repo_name}: {str(e)}")
return web.Response(
text=f"Error getting repository status: {str(e)}", status=500
)
@require_auth
async def list_repositories(self, request):
request["username"]
try:
repos = []
user_dir = self.REPO_DIR
if os.path.exists(user_dir):
for item in os.listdir(user_dir):
item_path = os.path.join(user_dir, item)
if os.path.isdir(item_path) and item.endswith(".git"):
repos.append(item[:-4])
if request.query.get("format") == "json":
return web.json_response({"repositories": repos})
else:
return web.Response(
text="\n".join(repos) if repos else "No repositories found"
)
except Exception as e:
logger.error(f"Error listing repositories: {str(e)}")
return web.Response(
text=f"Error listing repositories: {str(e)}", status=500
)
@require_auth
async def list_branches(self, request):
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
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
branches = [b.name for b in temp_repo.branches]
return web.json_response({"branches": branches})
@require_auth
async def create_branch(self, request):
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
try:
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
branch_name = data.get("branch_name")
start_point = data.get("start_point", "HEAD")
if not branch_name:
return web.Response(text="Branch name is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push("origin", branch_name)
logger.info(
f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
logger.error(
f"Error creating branch {branch_name} in {repo_name}: {str(e)}"
)
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
@require_auth
async def commit_log(self, request):
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
try:
limit = int(request.query.get("limit", 10))
branch = request.query.get("branch", "main")
except ValueError:
return web.Response(text="Invalid limit parameter", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
commits = []
try:
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
commits.append(
{
"id": commit.hexsha,
"short_id": commit.hexsha[:7],
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message.strip(),
}
)
except git.GitCommandError as e:
if "unknown revision or path" in str(e):
commits = []
else:
raise
return web.json_response(
{"repository": repo_name, "branch": branch, "commits": commits}
)
except Exception as e:
logger.error(f"Error getting commit log for {repo_name}: {str(e)}")
return web.Response(
text=f"Error getting commit log: {str(e)}", status=500
)
@require_auth
async def file_content(self, request):
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
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
try:
temp_repo.git.checkout(branch)
except git.GitCommandError:
return web.Response(text=f"Branch '{branch}' not found", status=404)
file_full_path = os.path.join(temp_dir, file_path)
if not os.path.exists(file_full_path):
return web.Response(
text=f"File '{file_path}' not found", status=404
)
if os.path.isdir(file_full_path):
files = os.listdir(file_full_path)
return web.json_response(
{
"repository": repo_name,
"path": file_path,
"type": "directory",
"contents": files,
}
)
else:
try:
with open(file_full_path) as f:
content = f.read()
return web.Response(text=content)
except UnicodeDecodeError:
return web.Response(
text=f"Cannot display binary file content for '{file_path}'",
status=400,
)
except Exception as e:
logger.error(f"Error getting file content from {repo_name}: {str(e)}")
return web.Response(
text=f"Error getting file content: {str(e)}", status=500
)
@require_auth
async def git_smart_http(self, request):
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():
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(
f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
)
return repo_dir
async def handle_info_refs(service):
repo_path = await get_repository_path()
logger.info(f"handle_info_refs: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
cmd = [service, "--stateless-rpc", "--advertise-refs", str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(
text=f"Git error: {stderr.decode()}", status=500
)
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": f"application/x-{service}-advertisement",
"Cache-Control": "no-cache",
},
)
await response.prepare(request)
packet = f"# service={service}\n"
length = len(packet) + 4
header = f"{length:04x}"
await response.write(f"{header}{packet}0000".encode())
await response.write(stdout)
return response
except Exception as e:
logger.error(f"Error handling info/refs: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
async def handle_service_rpc(service):
repo_path = await get_repository_path()
logger.info(f"handle_service_rpc: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
if (
not request.headers.get("Content-Type")
== f"application/x-{service}-request"
):
return web.Response(text="Invalid Content-Type", status=403)
body = await request.read()
cmd = [service, "--stateless-rpc", str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate(input=body)
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(
text=f"Git error: {stderr.decode()}", status=500
)
return web.Response(
body=stdout, content_type=f"application/x-{service}-result"
)
except Exception as e:
logger.error(f"Error handling service RPC: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
if request.method == "GET" and path.endswith("/info/refs"):
service = request.query.get("service")
if service in ("git-upload-pack", "git-receive-pack"):
return await handle_info_refs(service)
else:
return web.Response(
text="Smart HTTP requires service parameter", status=400
)
elif request.method == "POST" and "/git-upload-pack" in path:
return await handle_service_rpc("git-upload-pack")
elif request.method == "POST" and "/git-receive-pack" in path:
return await handle_service_rpc("git-receive-pack")
return web.Response(text="Not found", status=404)
if __name__ == "__main__":
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logger.info("Using uvloop for improved performance")
except ImportError:
logger.info("uvloop not available, using standard event loop")
app = GitApplication()
logger.info("Starting Git server on port 8080")
web.run_app(app, port=8080)
+21
View File
@@ -0,0 +1,21 @@
# retoor <retoor@molodetz.nl>
from snek.app import Application
from IPython import start_ipython
class Shell:
def __init__(self,db_path):
self.app = Application(db_path=f"sqlite:///{db_path}")
async def maintenance(self):
await self.app.services.container.maintenance()
await self.app.services.channel_message.maintenance()
def run(self):
ns = {
"app": self.app,
"maintenance": self.maintenance
}
start_ipython(argv=[], user_ns=ns)
+127
View File
@@ -0,0 +1,127 @@
# retoor <retoor@molodetz.nl>
import aiohttp
ENABLED = False
import asyncio
import json
import sqlite3
import aiohttp
from aiohttp import web
from sqlalchemy import event
from sqlalchemy.engine import Engine
queue = asyncio.Queue()
class State:
do_not_sync = False
async def sync_service(app):
if not ENABLED:
return
session = aiohttp.ClientSession()
async with session.ws_connect("http://localhost:3131/ws") as ws:
async def receive():
queries_synced = 0
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
State.do_not_sync = True
app.db.execute(*data)
app.db.commit()
State.do_not_sync = False
queries_synced += 1
print("queries synced: " + str(queries_synced))
print(*data)
await app.services.socket.broadcast_event()
except Exception as e:
print(e)
pass
# print(f"Received: {msg.data}")
elif msg.type == aiohttp.WSMsgType.ERROR:
break
async def write():
while True:
msg = await queue.get()
await ws.send_str(json.dumps(msg, default=str))
queue.task_done()
await asyncio.gather(receive(), write())
await session.close()
queries_queued = 0
# Attach a listener to log all executed statements
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
if not ENABLED:
return
global queries_queued
if State.do_not_sync:
print(statement, parameters)
return
if statement.startswith("SELECT"):
return
queue.put_nowait((statement, parameters))
queries_queued += 1
print("Queries queued: " + str(queries_queued))
async def websocket_handler(request):
queries_broadcasted = 0
ws = web.WebSocketResponse()
await ws.prepare(request)
request.app["websockets"].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
for client in request.app["websockets"]:
if client != ws:
await client.send_str(msg.data)
cursor = request.app["db"].cursor()
data = json.loads(msg.data)
queries_broadcasted += 1
cursor.execute(*data)
cursor.close()
print("Queries broadcasted: " + str(queries_broadcasted))
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f"WebSocket connection closed with exception {ws.exception()}")
request.app["websockets"].remove(ws)
return ws
app = web.Application()
app["websockets"] = []
app.router.add_get("/ws", websocket_handler)
async def on_startup(app):
app["db"] = sqlite3.connect("snek.db")
print("Server starting...")
async def on_cleanup(app):
for ws in app["websockets"]:
await ws.close()
app["db"].close()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=3131)
+83
View File
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path
import asyncssh
global _app
def set_app(app):
global _app
_app = app
def get_app():
return _app
logger = logging.getLogger(__name__)
roots = {}
class SFTPServer(asyncssh.SFTPServer):
def __init__(self, chan: asyncssh.SSHServerChannel):
self.root = get_app().services.user.get_home_folder_by_username(
chan.get_extra_info("username")
)
self.root.mkdir(exist_ok=True)
self.root = str(self.root)
super().__init__(chan, chroot=self.root)
def map_path(self, path):
mapped_path = Path(self.root).joinpath(path.lstrip(b"/").decode())
logger.debug(f"Mapping client path {path} to {mapped_path}")
return str(mapped_path).encode()
class SSHServer(asyncssh.SSHServer):
def password_auth_supported(self):
return True
def validate_password(self, username, password):
logger.debug(f"Validating credentials for user {username}")
result = get_app().services.user.authenticate_sync(username, password)
logger.info(f"Validating credentials for user {username}: {result}")
return result
async def start_ssh_server(app, host, port):
set_app(app)
logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
host_key_path.parent.mkdir(exist_ok=True, parents=True)
try:
if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}")
key = asyncssh.generate_private_key("ecdsa-sha2-nistp256")
key.write_private_key(host_key_path)
else:
logger.info(f"Loading existing host key from {host_key_path}")
key = asyncssh.read_private_key(host_key_path)
except Exception as e:
logger.error(f"Failed to generate or load host key: {e}")
raise
logger.info(f"Starting SFTP server on 127.0.0.1:{port}")
try:
x = await asyncssh.listen(
host=host,
port=port,
# process_factory=handle_client,
server_host_keys=[key],
server_factory=SSHServer,
sftp_factory=SFTPServer,
)
return x
except Exception:
logger.warning(f"Failed to start SFTP server. Already running.")
pass
+696
View File
@@ -0,0 +1,696 @@
/* retoor <retoor@molodetz.nl> */
html:has(body.admin-page) {
height: auto;
min-height: 100%;
}
body.admin-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.admin-page main {
overflow: visible;
flex: none;
}
:root {
--admin-bg: #000000;
--admin-bg-secondary: #0f0f0f;
--admin-bg-tertiary: #1a1a1a;
--admin-accent: #f05a28;
--admin-accent-hover: #e04924;
--admin-text: #e6e6e6;
--admin-text-muted: #888;
--admin-text-faded: #aaa;
--admin-border: #333;
--admin-border-light: #444;
--admin-danger: #8b0000;
--admin-danger-hover: #a00000;
--admin-warning: #c9a227;
--admin-success: #2e7d32;
}
.admin-content {
padding: 20px;
max-width: 1400px;
}
.admin-sidebar h2 {
color: var(--admin-accent);
font-size: 0.9em;
margin-bottom: 15px;
}
.admin-sidebar hr {
border: none;
border-top: 1px solid var(--admin-border);
margin: 20px 0;
}
.admin-stats {
margin-bottom: 30px;
}
.admin-stats h2 {
margin-bottom: 20px;
color: var(--admin-accent);
font-size: 1.3em;
}
.admin-stats h3 {
margin-top: 25px;
margin-bottom: 15px;
color: var(--admin-text-muted);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.time-cell {
font-size: 0.85em;
color: var(--admin-text-muted);
white-space: nowrap;
}
.stat-card {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 20px;
text-align: center;
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100px;
}
.stat-card:hover {
border-color: var(--admin-accent);
}
.stat-card.stat-highlight {
border-color: var(--admin-accent);
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
}
.stat-label {
display: block;
font-size: 0.75em;
color: var(--admin-text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-value {
display: block;
font-size: 2.5em;
font-weight: bold;
color: var(--admin-accent);
line-height: 1;
}
.stat-sub {
display: block;
font-size: 0.8em;
color: var(--admin-success);
margin-top: 8px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.info-item {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 15px;
}
.info-label {
display: block;
font-size: 0.8em;
color: var(--admin-text-muted);
text-transform: uppercase;
margin-bottom: 5px;
}
.info-value {
display: block;
font-size: 1.1em;
color: var(--admin-text);
}
.admin-activity {
margin-bottom: 30px;
}
.admin-activity h2 {
margin-bottom: 15px;
color: var(--admin-accent);
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--admin-bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.admin-table thead {
background: var(--admin-bg-tertiary);
}
.admin-table th {
text-align: left;
padding: 12px 15px;
color: var(--admin-text-muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--admin-border);
}
.admin-table td {
padding: 12px 15px;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text);
}
.admin-table tbody tr:hover {
background: var(--admin-bg-tertiary);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.message-preview,
.description-preview,
.value-preview,
.path-preview,
.endpoint-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-banned {
background: rgba(139, 0, 0, 0.1);
}
.row-inactive {
opacity: 0.6;
}
.row-pinned {
background: rgba(240, 90, 40, 0.1);
}
.row-locked {
opacity: 0.7;
}
.row-read {
opacity: 0.6;
}
.actions {
white-space: nowrap;
}
.btn {
display: inline-block;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--admin-border);
background: var(--admin-bg-tertiary);
color: var(--admin-text);
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.btn:hover {
background: #2a2a2a;
border-color: var(--admin-border-light);
color: #fff;
}
.btn-primary {
background: var(--admin-accent);
border-color: var(--admin-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--admin-accent-hover);
border-color: var(--admin-accent-hover);
}
.btn-secondary {
background: var(--admin-bg-tertiary);
border-color: var(--admin-border);
}
.btn-danger {
background: var(--admin-danger);
border-color: var(--admin-danger);
color: #fff;
}
.btn-danger:hover {
background: var(--admin-danger-hover);
border-color: var(--admin-danger-hover);
}
.btn-warning {
background: var(--admin-warning);
border-color: var(--admin-warning);
color: #000;
}
.btn-warning:hover {
background: #d4ab2a;
border-color: #d4ab2a;
}
.btn-small {
padding: 4px 10px;
font-size: 12px;
}
.admin-search {
margin-bottom: 20px;
}
.admin-search form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.search-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
width: 100%;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: var(--admin-accent);
}
.search-select {
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-width: 150px;
}
.search-select:focus {
outline: none;
border-color: var(--admin-accent);
}
.admin-pagination {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
padding: 15px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
}
.pagination-info {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 15px;
}
.pagination-pages {
color: var(--admin-text);
font-weight: 500;
}
.admin-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--admin-border);
}
.admin-header h2 {
color: var(--admin-accent);
margin-bottom: 5px;
}
.admin-header p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-form {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.admin-form h2 {
color: var(--admin-accent);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: var(--admin-text-muted);
font-size: 0.9em;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type="color"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--admin-accent);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--admin-text-muted);
font-size: 0.85em;
}
.form-group.checkbox label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.form-group.checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--admin-accent);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-end;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-actions-inline {
flex: 0 0 auto;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 25px;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.radio-group input[type="radio"] {
accent-color: var(--admin-accent);
}
.user-checkboxes {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 10px;
background: var(--admin-bg);
}
.checkbox-label {
display: block;
padding: 5px 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 10px;
accent-color: var(--admin-accent);
}
.danger-zone {
border-color: var(--admin-danger);
background: rgba(139, 0, 0, 0.05);
}
.danger-zone h2 {
color: var(--admin-danger);
}
.danger-zone p {
color: var(--admin-text-muted);
margin-bottom: 20px;
}
.admin-info {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
}
.admin-info h3 {
color: var(--admin-accent);
margin-bottom: 15px;
font-size: 1em;
}
.admin-info dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
}
.admin-info dt {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.admin-info dd {
color: var(--admin-text);
margin: 0;
}
.admin-list {
margin-bottom: 20px;
}
.admin-list h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
max-width: 600px;
}
.admin-actions-section h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-links {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
max-width: 600px;
}
.admin-links h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-links ul {
list-style: none;
}
.admin-links li {
margin-bottom: 10px;
}
.admin-links a {
color: var(--admin-accent);
text-decoration: none;
}
.admin-links a:hover {
text-decoration: underline;
}
.alert {
padding: 12px 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.alert-error {
background: rgba(139, 0, 0, 0.2);
border: 1px solid var(--admin-danger);
color: #ff6b6b;
}
.alert-success {
background: rgba(46, 125, 50, 0.2);
border: 1px solid var(--admin-success);
color: #81c784;
}
code {
background: var(--admin-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
@media (max-width: 768px) {
.admin-content {
padding: 15px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-table {
font-size: 0.9em;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.btn-small {
padding: 3px 8px;
font-size: 11px;
}
.form-row {
flex-direction: column;
}
.search-row {
flex-direction: column;
}
.search-input,
.search-select {
width: 100%;
}
}
+241 -341
View File
@@ -1,363 +1,263 @@
// Written by retoor@molodetz.nl
// retoor <retoor@molodetz.nl>
// This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints.
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient {
debug = false;
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
class RESTClient {
debug = false;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += '?' + encodedParams;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
}
async post(url, data) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
async post(url, data) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
}
}
class EventHandler {
constructor() {
this.subscribers = {};
export class Chat extends EventHandler {
constructor() {
super();
this._url =
window.location.hostname === "localhost"
? "ws://localhost/chat.ws"
: "wss://" + window.location.hostname + "/chat.ws";
this._socket = null;
this._waitConnect = null;
this._promises = {};
}
connect() {
if (this._waitConnect) {
return this._waitConnect;
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
emit(type, ...data) {
if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));
}
}
class Chat extends EventHandler {
constructor() {
super();
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
this._socket = null;
this._waitConnect = null;
this._promises = {};
}
connect() {
if (this._waitConnect) {
return this._waitConnect;
}
return new Promise((resolve) => {
this._waitConnect = resolve;
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
generateUniqueId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
call(method, ...args) {
return new Promise((resolve, reject) => {
try {
const command = { method, args, message_id: this.generateUniqueId() };
this._promises[command.message_id] = resolve;
this._socket.send(JSON.stringify(command));
} catch (e) {
reject(e);
}
});
}
_connected() {
this._socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.message_id && this._promises[message.message_id]) {
this._promises[message.message_id](message);
delete this._promises[message.message_id];
} else {
this.emit("message", message);
}
};
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit('close');
};
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
}
class Socket extends EventHandler {
ws = null;
isConnected = null;
isConnecting = null;
url = null;
connectPromises = [];
ensureTimer = null;
constructor() {
super();
this.url = window.location.hostname === 'localhost' ? 'ws://localhost:8081/rpc.ws' : 'wss://' + window.location.hostname + '/rpc.ws';
this.ensureConnection();
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
}
get client() {
const me = this;
return new Proxy({}, {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
};
},
});
}
ensureConnection() {
if (this.ensureTimer) {
return this.connect();
}
const me = this;
this.ensureTimer = setInterval(() => {
if (me.isConnecting) me.isConnecting = false;
me.connect();
}, 5000);
return this.connect();
}
generateUniqueId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
connect() {
return new Promise((resolve) => {
this._waitConnect = resolve;
//console.debug("Connecting..");
const me = this
if (this.isConnected || this.isConnecting) {
return new Promise((resolve) => {
me.connectPromises.push(resolve);
if (!me.isConnecting) resolve(me);
});
}
this.isConnecting = true;
return new Promise((resolve) => {
this.connectPromises.push(resolve);
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
const ws = new WebSocket(this.url);
ws.onopen = () => {
this.ws = ws;
this.isConnected = true;
this.isConnecting = false;
ws.onmessage = (event) => {
this.onData(JSON.parse(event.data));
};
ws.onclose = () => {
this.onClose();
};
ws.onerror = () => {
this.onClose();
};
this.onConnect()
this.connectPromises.forEach(resolver => resolver(this));
};
});
}
onConnect(){
this.emit("connected")
}
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(data);
}
if (data.callId) {
this.emit(data.callId, data.data);
}
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
this.emit("channel-message", data);
}
}
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
async sendJson(data) {
await this.connect().then(api => {
api.ws.send(JSON.stringify(data));
});
}
generateUniqueId() {
return "id-" + Math.random().toString(36).substr(2, 9);
}
async call(method, ...args) {
const call = {
callId: this.generateUniqueId(),
method,
args,
};
const me = this
return new Promise((resolve) => {
me.addEventListener(call.callId, data => resolve(data));
me.sendJson(call);
});
}
call(method, ...args) {
return new Promise((resolve, reject) => {
try {
const command = { method, args, message_id: this.generateUniqueId() };
this._promises[command.message_id] = resolve;
this._socket.send(JSON.stringify(command));
} catch (e) {
reject(e);
}
});
}
onClose() {
console.info("Connection lost. Reconnecting.");
this.isConnected = false;
this.isConnecting = false;
this.ws.close();
this.ws = null;
this.ensureConnection().then(() => {
console.info("Reconnected.");
});
}
_connected() {
this._socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.message_id && this._promises[message.message_id]) {
this._promises[message.message_id](message);
delete this._promises[message.message_id];
} else {
this.emit("message", message);
}
};
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit("close");
};
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
}
class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
export class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
sounds = ["/audio/soundfx.d_beep3.mp3"];
sounds = {
message: "/audio/soundfx.d_beep3.mp3",
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
messageOtherChannel:
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
};
play(soundIndex = 0) {
this.schedule.delay(() => {
new Audio(this.sounds[soundIndex]).play()
.then(() => {
console.debug("Gave sound notification");
})
.catch(error => {
console.error("Notification failed:", error);
});
});
}
}
class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
async ping(...args) {
if(this.is_pinging)return false
this.is_pinging = true
await this.rpc.ping(...args);
this.is_pinging = false
}
async forcePing(...arg) {
await this.rpc.ping(...args);
}
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.is_pinging = false
this.ping_interval = setInterval(()=>{
this.ping("active")
}, 15000)
const me = this
this.ws.addEventListener("connected", (data)=> {
this.ping("online")
play(soundIndex = 0) {
this.schedule.delay(() => {
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
//console.debug("Gave sound notification");
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
.catch((error) => {
console.error("Notification failed:", error);
});
this.rpc.getUser(null).then(user => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
async benchMark(times = 100, message = "Benchmark Message") {
const promises = [];
const me = this;
for (let i = 0; i < times; i++) {
promises.push(this.rpc.getChannels().then(channels => {
channels.forEach(channel => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}));
}
}
});
}
}
const app = new App();
export class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false;
presenceNotification = null;
async set_typing(channel_uid) {
this.typeEventChannelUid = channel_uid;
}
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) {
if (this.is_pinging) return false;
this.is_pinging = true;
await this.rpc.ping(...args);
this.is_pinging = false;
}
async forcePing(...arg) {
await this.rpc.ping(...arg);
}
starField = null
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.presenceNotification = new PresenceNotification(this.ws);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
}, 15000);
this.typeEventChannelUid = null;
this.typeListener = setInterval(() => {
if (this.typeEventChannelUid) {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
}, 1000);
const me = this;
this.ws.addEventListener("connected", (data) => {
this.ping("online");
});
this.ws.addEventListener("reconnecting", (data) => {
this.starField?.showNotify("Connecting..","#CC0000")
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.ws.addEventListener("data", (data) => {
if(this._debug){
console.debug(data)
}
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? "days" : "day"} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
}
return "just now";
}
async benchMark(times = 100, message = "Benchmark Message") {
const promises = [];
const me = this;
for (let i = 0; i < times; i++) {
promises.push(
this.rpc.getChannels().then((channels) => {
channels.forEach((channel) => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}),
);
}
}
}
export const app = new App();
window.app = app;
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
.back-form {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
fancy-button {
grid-column: 1 / 1;
grid-row: 1 / 1;
z-index: 1;
margin-left: 30px;
margin-top: 30px;
}
generic-form {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
}
+627 -64
View File
@@ -1,8 +1,18 @@
@import "shared.css";
* {
margin: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
.hidden {
display: none;
}
.gallery {
padding: 50px;
height: auto;
@@ -20,23 +30,44 @@
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
background-color: #000000;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%;
height: 100%;
}
html:has(body.profile-page) {
height: auto;
min-height: 100%;
}
body.profile-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.profile-page main {
overflow: visible;
flex: none;
}
main {
display: flex;
flex: 1;
overflow: hidden;
grid-area: chat-area;
}
header {
background-color: #0f0f0f;
grid-area: header;
padding: 10px 20px;
display: flex;
justify-content: space-between;
@@ -45,27 +76,31 @@ header {
header .logo {
color: #fff;
font-size: 1.5em;
font-weight: bold;
font-size: 1.2em;
color: #fff;
}
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 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
header nav a:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
a {
@@ -74,18 +109,26 @@ a {
margin-bottom: 3px;
}
h1 {
font-size: 2em;
color: #f05a28;
}
h2 {
font-size: 1.4em;
color: #f05a28;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background-color: #1a1a1a;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
background-color: #0f0f0f;
border-bottom: 1px solid #333;
user-select: none;
}
@@ -95,42 +138,60 @@ a {
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.message-list {
flex: 1;
height: 200px;
height: 10px;
padding-bottom: 40px;
overflow-y: auto;
}
.chat-messages {
.chat-messages, .threads {
flex: 1;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 10px;
height: 200px;
background: #1a1a1a;
height: 10px;
}
.chat-messages {
display: flex;
flex-direction: column-reverse;
}
.container {
flex: 1;
padding: 10px;
ul {
list-style: none;
margin: 0;
padding: 0;
}
a {
font-size: 20px;
color: #f05a28;
}
font-size: 20px;
color: #f05a28;
}
}
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
.chat-messages .message {
.chat-messages .message, .threads .thread {
display: flex;
align-items: flex-start;
margin-bottom: 0;
@@ -138,7 +199,7 @@ a {
border-radius: 8px;
}
.chat-messages .message .avatar {
.chat-messages .message .avatar, .threads .thread .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
@@ -152,62 +213,113 @@ a {
margin-right: 15px;
}
.chat-messages .message .message-content {
.chat-messages .message .message-content, .threads .thread .message-content {
flex: 1;
}
.chat-messages .message .message-content .author {
.chat-messages .message .message-content .author, .threads .thread .message-content .author {
font-weight: bold;
color: #f05a28;
margin-bottom: 3px;
}
* {
word-break: break-word;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
hyphens: auto;
}
.highlight pre {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.chat-messages .message .message-content .text {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.chat-messages .message .message-content .text, .threads .thread .message-content .text {
margin-bottom: 5px;
color: #e6e6e6;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
hyphens: auto;
}
.message-content .spoiler {
background-color: rgba(255, 255, 255, 0.1);
/*color: transparent;*/
cursor: pointer;
border-radius: 0.5rem;
padding: 0.5rem;
position: relative;
height: 2.5rem;
overflow: hidden;
max-width: unset;
}
.message-content .spoiler * {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active {
/*color: #e6e6e6;*/
/*transition: color 0.3s ease-in;*/
height: unset;
overflow: unset;
}
@keyframes delay-pointer-events {
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
.spoiler:hover * {
animation: unset;
}
.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * {
opacity: 1;
transition: opacity 0.3s ease-in;
pointer-events: auto;
visibility: visible;
animation: delay-pointer-events 0.2s linear;
}
.message-content {
max-width: 100%;
max-width: 100%;
}
.message-content {
img, video, iframe, div {
max-width: 100%;
}
img, video, iframe, div {
max-width: 90%;
border-radius: 20px;
}
}
.chat-messages .message .message-content .time {
.chat-messages .message .message-content .time, .threads .thread .message-content .time {
font-size: 0.8em;
color: #aaa;
}
.chat-input {
padding: 15px;
background-color: #121212;
display: flex;
align-items: center;
border-top: 1px solid #333;
}
input[type="text"], .chat-input textarea {
flex: 1;
background-color: #1a1a1a;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
@@ -215,7 +327,6 @@ input[type="text"], .chat-input textarea {
}
.chat-input upload-button {
background-color: #f05a28;
color: white;
border: none;
padding: 10px 15px;
@@ -246,22 +357,29 @@ input[type="text"], .chat-input textarea {
.avatar {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.author, .time {
.author {
display: none;
}
&:not(:hover, :focus-within, :active) .time {
opacity: 0;
}
}
.message.switch-user {
.text img,iframe, video {
.text img, iframe, video {
max-width: 90%;
border-radius: 20px;
}
.avatar {
user-select: none;
opacity: 1;
max-height: unset;
}
.author {
@@ -269,15 +387,25 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:last-child{
.time {
display: block;
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
.time {
display: block;
opacity: 1;
}
}
.message.highlighted {
animation: mention-highlight 2s ease-out;
}
@keyframes mention-highlight {
0% { background-color: rgba(240, 90, 40, 0.3); }
100% { background-color: transparent; }
}
/* The entire scrollbar */
::-webkit-scrollbar {
display:none;
display: none;
}
::-webkit-scrollbar-track {
@@ -294,44 +422,479 @@ input[type="text"], .chat-input textarea {
background-color: #555;
}
.chat-messages {
.chat-messages, .threads {
scrollbar-width: none;
scrollbar-color: #888 #f1f1f1;
}
a {
text-decoration:none
text-decoration: none
}
.sidebar {
width: 250px;
background-color: #121212;
padding: 20px;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
overflow-y: auto;
border-right: 1px solid #333;
grid-area: sidebar;
}
.sidebar h2 {
color: #f05a28;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-top: 20px;
}
.sidebar h2:first-child {
margin-top: 0;
}
.sidebar-add-btn {
color: #f05a28;
text-decoration: none;
font-size: 1.2em;
margin-bottom: 20px;
font-weight: bold;
margin-left: 8px;
opacity: 0.7;
transition: opacity 0.2s;
}
.sidebar-add-btn:hover {
opacity: 1;
}
.sidebar ul {
list-style: none;
}
.sidebar ul li {
margin-bottom: 15px;
}
.sidebar ul li {
margin-bottom: 4px;
}
.sidebar ul li a {
color: #ccc;
color: #888;
text-decoration: none;
font-size: 1em;
transition: color 0.3s;
font-family: 'Courier New', monospace;
font-size: 0.9em;
display: block;
padding: 6px 10px;
border-radius: 4px;
border-left: 2px solid transparent;
transition: all 0.2s ease;
}
.sidebar ul li a:hover {
color: #e6e6e6;
background-color: #1a1a1a;
border-left-color: #444;
}
.sidebar ul li a.active {
color: #f05a28;
border-left-color: #f05a28;
}
@keyframes glow {
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
}
.glow {
animation: glow 1s;
}
@media only screen and (max-width: 768px) {
header {
top: 0;
left: 0;
width: 100%;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px;
padding: 10px 12px;
}
header > * {
display: inline-block !important;
vertical-align: middle;
}
header .logo {
flex: 1 1 auto !important;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
header .logo h2 {
font-size: 14px;
}
header nav {
display: none !important;
}
header nav-menu,
header channel-menu {
flex: 0 0 auto !important;
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
}
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 12px;
padding: 24px;
background-color: #000; /* Deep black */
color: #f1f1f1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
width: 90%;
max-width: 400px;
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
z-index: 1000;
}
/* Backdrop styling */
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Title and content */
dialog .dialog-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 16px;
color: #fff;
}
dialog .dialog-content {
font-size: 1rem;
color: #ccc;
margin-bottom: 20px;
}
/* Button layout */
dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
dialog .dialog-button {
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #e6e6e6;
cursor: pointer;
transition: all 0.2s ease;
}
dialog .dialog-button:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
dialog .dialog-button.primary:hover {
background-color: #e04924;
border-color: #e04924;
}
dialog .dialog-button.secondary {
background-color: #2a2a2a;
border-color: #444;
color: #e6e6e6;
}
dialog .dialog-button.secondary:hover {
background-color: #3a3a3a;
border-color: #555;
}
dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] {
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
dialog .dialog-button.secondary:disabled,
dialog .dialog-button.secondary[aria-disabled="true"] {
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
dialog .dialog-button:disabled:focus {
outline: none;
}
dialog .dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
}
dialog .dialog-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
dialog .dialog-input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
dialog .dialog-input::placeholder {
color: #555;
}
dialog .dialog-input.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
dialog .dialog-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: #e6e6e6;
font-size: 14px;
cursor: pointer;
}
dialog .dialog-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #f05a28;
}
dialog .dialog-suggestions {
max-height: 150px;
overflow-y: auto;
}
dialog .dialog-suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
dialog .dialog-suggestion-item:hover {
background-color: #1a1a1a;
}
dialog .dialog-box-wide {
max-width: 500px;
}
dialog .dialog-label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
dialog .dialog-label-danger {
color: #8b0000;
}
dialog .dialog-divider {
height: 1px;
background-color: #333;
margin: 16px 0;
}
dialog .dialog-danger-zone {
padding: 12px;
border: 1px solid #8b0000;
border-radius: 4px;
background-color: rgba(139, 0, 0, 0.1);
}
dialog .dialog-button.danger {
background-color: #8b0000;
border-color: #8b0000;
color: #fff;
}
dialog .dialog-button.danger:hover {
background-color: #a00000;
border-color: #a00000;
}
dialog .dialog-button.danger:disabled {
background-color: #0a0a0a;
border-color: #222;
color: #555;
cursor: not-allowed;
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
.message-content table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 0.9em;
background-color: var(--bg-secondary, #1e1e1e);
border-radius: 6px;
overflow: hidden;
}
.message-content thead th {
background-color: var(--accent-color, #f05a28);
color: #fff;
font-weight: 600;
text-align: left;
padding: 10px 14px;
min-width: 100px;
border: none;
}
.message-content tbody td {
padding: 8px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: #e6e6e6;
}
.message-content tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.03);
}
.message-content tbody tr:hover {
background-color: rgba(240, 90, 40, 0.08);
}
.message-content .table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
+93
View File
@@ -0,0 +1,93 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
background: #1a1a1a;
color: #e6e6e6;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
.btn:active {
background: #111;
transform: translateY(1px);
}
.btn:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
border-color: #222;
}
.btn-primary {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
.btn-primary:hover {
background: #e04924;
border-color: #e04924;
}
.btn-secondary {
background: #2a2a2a;
border-color: #444;
}
.btn-secondary:hover {
background: #3a3a3a;
border-color: #555;
}
.btn-danger {
background: #1a1a1a;
border-color: #8b0000;
color: #ff6b6b;
}
.btn-danger:hover {
background: #2a1515;
border-color: #b00;
}
.btn-success {
background: #1a1a1a;
border-color: #006400;
color: #6bff6b;
}
.btn-success:hover {
background: #152a15;
border-color: #0b0;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 14px 28px;
font-size: 16px;
}
.btn-block {
display: flex;
width: 100%;
}
+123
View File
@@ -0,0 +1,123 @@
/* retoor <retoor@molodetz.nl> */
channel-menu {
display: none;
position: relative;
}
@media (max-width: 768px) {
channel-menu {
display: inline-flex !important;
align-items: center;
}
}
.channel-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: none;
color: #888;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.2em;
transition: all 0.2s ease;
}
.channel-menu-toggle:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
channel-menu[open] .channel-menu-toggle {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-panel {
position: fixed;
top: 50px;
right: 8px;
left: 8px;
background-color: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px 0;
display: none;
flex-direction: column;
max-height: calc(100vh - 70px);
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
channel-menu[open] .channel-menu-panel {
display: flex;
}
.channel-menu-section {
padding: 8px 16px 4px;
font-size: 0.75em;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.channel-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
color: #888;
text-decoration: none;
font-size: 0.95em;
transition: all 0.2s ease;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.channel-menu-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-item.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.channel-menu-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-menu-count {
background: #f05a28;
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.8em;
min-width: 20px;
text-align: center;
}
.channel-menu-empty {
padding: 16px;
color: #666;
font-size: 0.9em;
text-align: center;
}
.channel-menu-divider {
height: 1px;
background: #333;
margin: 8px 0;
}
+171
View File
@@ -0,0 +1,171 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
class ChannelMenu extends HTMLElement {
constructor() {
super();
this._isOpen = false;
this._channels = [];
this._boundClickOutside = this._handleClickOutside.bind(this);
this._container = document.createElement('div');
this._container.className = 'channel-menu-container';
this._toggleButton = document.createElement('button');
this._toggleButton.className = 'channel-menu-toggle';
this._toggleButton.setAttribute('aria-label', 'Toggle channel menu');
this._toggleButton.innerHTML = '💬';
this._toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._menuPanel = document.createElement('div');
this._menuPanel.className = 'channel-menu-panel';
this._container.appendChild(this._toggleButton);
this._container.appendChild(this._menuPanel);
this.appendChild(this._container);
}
async connectedCallback() {
await this._loadChannels();
app.addEventListener('channel-message', (data) => {
if (data.is_final && data.channel_uid) {
this._incrementCount(data.channel_uid);
}
});
}
async _loadChannels() {
try {
this._channels = await app.rpc.getChannels();
this._renderChannels();
} catch (e) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">Failed to load channels</div>';
}
}
_renderChannels() {
this._menuPanel.innerHTML = '';
const publicChannels = this._channels.filter(c => !c.is_private);
const privateChannels = this._channels.filter(c => c.is_private);
if (publicChannels.length > 0) {
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Channels';
this._menuPanel.appendChild(header);
publicChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (privateChannels.length > 0) {
if (publicChannels.length > 0) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
this._menuPanel.appendChild(divider);
}
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Private';
this._menuPanel.appendChild(header);
privateChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (this._channels.length === 0) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">No channels available</div>';
}
}
_createChannelItem(channel) {
const item = document.createElement('a');
item.className = 'channel-menu-item';
item.href = `/channel/${channel.uid}.html`;
item.dataset.channelUid = channel.uid;
if (window.location.pathname.includes(channel.uid)) {
item.classList.add('active');
}
const name = document.createElement('span');
name.className = 'channel-menu-name';
name.textContent = channel.name;
if (channel.color) {
name.style.color = channel.color;
}
item.appendChild(name);
if (channel.new_count > 0) {
const count = document.createElement('span');
count.className = 'channel-menu-count';
count.textContent = channel.new_count;
item.appendChild(count);
}
item.addEventListener('click', () => {
this._close();
});
return item;
}
_incrementCount(channelUid) {
const item = this._menuPanel.querySelector(`[data-channel-uid="${channelUid}"]`);
if (item && !item.classList.contains('active')) {
let countEl = item.querySelector('.channel-menu-count');
if (!countEl) {
countEl = document.createElement('span');
countEl.className = 'channel-menu-count';
countEl.textContent = '1';
item.appendChild(countEl);
} else {
const current = parseInt(countEl.textContent) || 0;
countEl.textContent = current + 1;
}
}
}
_toggle() {
if (this._isOpen) {
this._close();
} else {
this._open();
}
}
_open() {
this._isOpen = true;
this.setAttribute('open', '');
this._loadChannels();
setTimeout(() => {
document.addEventListener('click', this._boundClickOutside);
}, 0);
}
_close() {
this._isOpen = false;
this.removeAttribute('open');
document.removeEventListener('click', this._boundClickOutside);
}
_handleClickOutside(event) {
if (!this.contains(event.target)) {
this._close();
}
}
disconnectedCallback() {
document.removeEventListener('click', this._boundClickOutside);
}
}
customElements.define('channel-menu', ChannelMenu);
+559 -49
View File
@@ -1,69 +1,579 @@
// Written by retoor@molodetz.nl
// retoor <retoor@molodetz.nl>
// This JavaScript class defines a custom HTML element for a chat input widget, featuring a text area and an upload button. It handles user input and triggers events for input changes and message submission.
import { app } from "./app.js";
import { NjetComponent, eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
import { loggerFactory } from "./logger.js";
import "./toolbar-menu.js";
import "./mention-nav.js";
// Includes standard DOM manipulation methods; no external imports used.
const log = loggerFactory.getLogger("ChatInput");
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
class ChatInputComponent extends NjetComponent {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
};
class ChatInputElement extends HTMLElement {
_chatWindow = null
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
},
"/leet": () => {
this.value = this.textToLeet(this.value);
this._leetSpeak = !this._leetSpeak;
},
"/l33t": () => {
this._leetSpeakAdvanced = !this._leetSpeakAdvanced;
}
};
users = [];
textarea = null;
_value = "";
lastUpdateEvent = null;
queuedMessage = null;
lastMessagePromise = null;
_leetSpeak = false;
_leetSpeakAdvanced = false;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.textarea.classList.add("chat-input-textarea");
this.value = this.getAttribute("value") || "";
}
set chatWindow(value){
this._chatWindow = value
get value() {
return this._value;
}
get chatWindow(){
return this._chatWindow
}
get channelUid() {
return this.chatWindow.channel.uid
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.container = document.createElement('div');
this.container.classList.add('chat-input');
this.container.innerHTML = `
<textarea placeholder="Type a message..." rows="2"></textarea>
<upload-button></upload-button>
`;
this.textBox = this.container.querySelector('textarea');
this.uploadButton = this.container.querySelector('upload-button');
this.uploadButton.chatInput = this
this.textBox.addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));
const message = e.target.value;
const button = this.container.querySelector('button');
button.disabled = !message;
set value(value) {
this._value = value;
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions);
}
resolveAutoComplete(input) {
let value = null;
for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(input.split(" ", 1)[0])) {
if (value) {
return null;
}
value = key;
}
}
return value;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick]);
}
extractMentions(text) {
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,
};
});
}
this.textBox.addEventListener('change', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
console.error(e.target.value);
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 };
});
}
this.textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (!message) return;
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
e.target.value = '';
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}`);
}
});
this.component.appendChild(this.container);
return updatedText;
}
matchMentions2ToAuthors(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 };
});
}
textToLeet(text) {
// L33t speak character mapping
const leetMap = {
'a': '4',
'A': '4',
'e': '3',
'E': '3',
'i': '1',
'I': '1',
'o': '0',
'O': '0',
's': '5',
'S': '5',
't': '7',
'T': '7',
'l': '1',
'L': '1',
'g': '9',
'G': '9',
'b': '6',
'B': '6',
'z': '2',
'Z': '2'
};
// Convert text to l33t speak
return text.split('').map(char => {
return leetMap[char] || char;
}).join('');
}
// Advanced version with random character selection
textToLeetAdvanced(text) {
const leetMap = {
'a': ['4', '@', '/\\'],
'A': ['4', '@', '/\\'],
'e': ['3', '€'],
'E': ['3', '€'],
'i': ['1', '!', '|'],
'I': ['1', '!', '|'],
'o': ['0', '()'],
'O': ['0', '()'],
's': ['5', '$'],
'S': ['5', '$'],
't': ['7', '+'],
'T': ['7', '+'],
'l': ['1', '|'],
'L': ['1', '|'],
'g': ['9', '6'],
'G': ['9', '6'],
'b': ['6', '|3'],
'B': ['6', '|3'],
'z': ['2'],
'Z': ['2'],
'h': ['#', '|-|'],
'H': ['#', '|-|'],
'n': ['|\\|'],
'N': ['|\\|'],
'm': ['|\\/|'],
'M': ['|\\/|'],
'w': ['\\/\\/'],
'W': ['\\/\\/'],
'v': ['\\/', 'V'],
'V': ['\\/', 'V'],
'u': ['|_|'],
'U': ['|_|'],
'r': ['|2'],
'R': ['|2'],
'f': ['|='],
'F': ['|='],
'd': ['|)'],
'D': ['|)'],
'c': ['(', '['],
'C': ['(', '['],
'k': ['|<'],
'K': ['|<'],
'p': ['|>'],
'P': ['|>'],
'x': ['><'],
'X': ['><'],
'y': ['`/'],
'Y': ['`/']
};
return text.split('').map(char => {
const options = leetMap[char];
if (options) {
return options[Math.floor(Math.random() * options.length)];
}
return char;
}).join('');
}
async connectedCallback() {
this.user = null;
app.rpc.getUser(null).then((user) => {
this.user = user;
});
this.liveType = this.getAttribute("live-type") == "true";
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users;
});
this.messageUid = null;
this.classList.add("chat-input");
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.parentElement.insertBefore(this.fileUploadGrid, this);
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.sttButton = document.createElement("stt-button");
this.ttsButton = document.createElement("tts-button");
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
this.dispatchEvent(new CustomEvent("upload", e));
});
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
});
this.uploadButton.addEventListener("click", (e) => {
e.preventDefault();
this.fileUploadGrid.openFileDialog();
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
});
this.mentionNav = document.createElement('mention-nav');
this.mentionNav.setAttribute('channel', this.channelUid);
this.mentionNav.addEventListener('navigate', async (e) => {
const messageList = document.querySelector('message-list');
const { messageUid, mentionUid } = e.detail;
if (messageList && messageList.scrollToMessage(messageUid)) {
await app.rpc.markMentionRead(mentionUid);
this.mentionNav.decrementCount();
}
});
this.toolbarMenu = document.createElement("toolbar-menu");
this.toolbarMenu.addButton(this.mentionNav, 'mention');
this.toolbarMenu.addButton(this.sttButton, 'stt');
this.toolbarMenu.addButton(this.ttsButton, 'tts');
this.toolbarMenu.addButton(this.uploadButton, 'upload');
this.appendChild(this.toolbarMenu);
this.textarea.addEventListener("blur", () => {
this.updateFromInput(this.value, true).then(
this.updateFromInput("")
)
});
this.subscribe("file-uploads-done", (data)=>{
this.fileUploadGrid.style.display = "none";
let msg =data.reduce((message, file) => {
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
}, '');
app.rpc.sendMessage(this.channelUid, msg, true);
});
this.textarea.addEventListener("change",(e)=>{
this.value = this.textarea.value;
this.updateFromInput(e.target.value);
})
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid);
return;
}
this.updateFromInput(e.target.value);
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
}
if (e.repeat) {
this.updateFromInput(e.target.value);
}
});
this.addEventListener("upload", (e) => {
this.focus();
});
this.addEventListener("uploaded", function (e) {
let message = e.detail.files.reduce((message, file) => {
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
}, '');
app.rpc.sendMessage(this.channelUid, message, true);
});
setTimeout(() => {
this.focus();
}, 1000);
}
trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
}
return i === s.length && s.length > 1;
}
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
log.debug("Flagging typing indicator", { channelUid: this.channelUid });
app.rpc.set_typing(this.channelUid, this.user?.color).catch((e) => {
log.warn("set_typing failed", { error: e, channelUid: this.channelUid });
});
}
}
finalizeMessage(messageUid) {
let value = this.value;
value = this.replaceMentionsWithAuthors(value)
if(this._leetSpeak){
value = this.textToLeet(value);
}else if(this._leetSpeakAdvanced){
value = this.textToLeetAdvanced(value);
}
log.info("Finalizing message", { channelUid: this.channelUid, messageLength: value.length, messageUid });
app.rpc.sendMessage(this.channelUid, value , true).then((result) => {
log.debug("Message finalized successfully", { channelUid: this.channelUid, result });
}).catch((e) => {
log.error("Failed to finalize message", { channelUid: this.channelUid, error: e });
});
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
this.lastMessagePromise = null;
}
updateFromInput(value, isFinal = false) {
log.debug("updateFromInput called", { valueLength: value?.length, isFinal, liveType: this.liveType, channelUid: this.channelUid });
this.value = value;
this.flagTyping();
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
log.debug("Sending live type message", { channelUid: this.channelUid, messageLength: messageText?.length, isFinal: !this.liveType || isFinal });
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid;
}
}
async sendMessage(channelUid, value, is_final) {
log.info("sendMessage called", { channelUid, valueLength: value?.length, is_final });
try {
const result = await app.rpc.sendMessage(channelUid, value, is_final);
log.debug("sendMessage completed", { channelUid, result, is_final });
return result;
} catch (e) {
log.error("sendMessage failed", { channelUid, error: e, is_final });
throw e;
}
}
}
customElements.define('chat-input', ChatInputElement);
customElements.define("chat-input", ChatInputComponent);

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