Compare commits

160 Commits
Author SHA1 Message Date
retoor a42c2bdf5d Added search user. 2025-02-09 01:42:50 +01:00
retoor afa40ada77 No select. 2025-02-09 01:42:34 +01:00
retoor bda5cfd52d No select. 2025-02-09 01:38:01 +01:00
retoor ad4847a78e No select. 2025-02-09 01:34:02 +01:00
retoor b169fa4792 No select. 2025-02-09 01:31:34 +01:00
retoor 06b539b884 Fixed.. 2025-02-08 23:48:42 +01:00
retoor a8fea31a32 Fixed.. 2025-02-08 22:03:26 +01:00
retoor 5154811b29 CSS Fixes. 2025-02-08 21:54:46 +01:00
retoor 60ca3ec791 Added search form. 2025-02-08 17:59:38 +01:00
retoor 49eb76dc8b Added search form. 2025-02-08 17:33:02 +01:00
retoor d7b943dc8c Changes. 2025-02-08 17:31:03 +01:00
retoor 8d0d709e18 Fix template. 2025-02-08 17:14:37 +01:00
retoor fcb05903f3 Fix template. 2025-02-08 17:09:05 +01:00
retoor f291c0f2e4 Fix. 2025-02-08 17:07:04 +01:00
retoor 7ca2bc5776 Removed double sockets. 2025-02-07 19:45:05 +01:00
retoor 9840c8eb03 Applied emoticons. 2025-02-07 19:42:03 +01:00
retoor 51f1b1d86e Changed markdown. 2025-02-07 18:21:41 +01:00
retoor cfa2af61b8 Changes formatter. 2025-02-07 18:09:15 +01:00
retoor a301e2c5bf Changes formatter. 2025-02-07 18:06:48 +01:00
retoor d4aaa2d66b Changes formatter. 2025-02-07 18:02:10 +01:00
retoor 386d9c3aae Fixed markdown. 2025-02-07 17:49:29 +01:00
retoor 203314b209 Updated False if failed login. 2025-02-06 17:31:14 +01:00
retoor b6185a95f3 Added :snek1: 2025-02-05 19:11:11 +01:00
retoor 6f9adfe67f Drive service. 2025-02-04 23:38:13 +01:00
retoor 084f8dba20 Heavy repair. 2025-02-03 21:15:18 +01:00
retoor f395d16173 Useless button + wrapping 2025-02-03 20:46:41 +01:00
retoor b48a901e33 Non working upload button. 2025-02-03 20:45:29 +01:00
retoor fe707dca4e Back to default. 2025-02-03 01:30:43 +01:00
retoor f4a5536dcf Added highlight. 2025-02-03 01:28:48 +01:00
retoor 079187e1b4 Added highlight. 2025-02-03 01:27:21 +01:00
retoor 83cc0f6137 Added highlight. 2025-02-03 01:24:09 +01:00
retoor 38a24e9a12 Updated CSS. 2025-02-03 00:10:09 +01:00
retoor 23c8ebca73 Updated CSS. 2025-02-03 00:08:17 +01:00
retoor 3ae43c84e7 Wrapped highlight.css 2025-02-03 00:07:33 +01:00
retoor 7d750db1f8 Fixed own notification issue. 2025-02-02 23:21:43 +01:00
retoor 99fc9118b3 Fixed audio notification 2025-02-02 23:14:00 +01:00
retoor a06e3f404a highlight 2025-02-01 19:41:38 +01:00
retoor 98d89dbc5f highlight 2025-02-01 19:38:35 +01:00
retoor e0ed4491b4 Added highlight. 2025-02-01 19:35:27 +01:00
retoor feeb94c9cf Removed Z 2025-02-01 17:39:57 +01:00
retoor 928969b8b6 Added emoji's 2025-02-01 05:41:03 +01:00
retoor 4185bb3a69 Changed validation. 2025-02-01 00:01:23 +01:00
retoor 0c68c4e622 Fixed autoescape. 2025-01-31 22:07:01 +01:00
retoor f6f9968430 Word break 2025-01-31 19:47:03 +01:00
retoor 3796c7c547 Word break 2025-01-31 19:46:02 +01:00
retoor 663ab41510 Word break 2025-01-31 19:36:59 +01:00
retoor ae5fffe5e0 Word break 2025-01-31 19:33:18 +01:00
retoor 1999a6c8d8 Word break 2025-01-31 19:24:50 +01:00
retoor 7526bcc816 Word break 2025-01-31 19:17:40 +01:00
retoor efe12644ed Unbuffered. 2025-01-31 13:47:56 +01:00
retoor cfd3e7881e Unbuffered. 2025-01-31 13:45:41 +01:00
retoor 495543144d Unbuffered. 2025-01-31 13:43:01 +01:00
retoor 2ae2e8450c Unbuffered. 2025-01-31 13:42:12 +01:00
retoor 88749ce05c Unbuffered. 2025-01-31 13:39:48 +01:00
retoor 10c7232a8f Unbuffered. 2025-01-31 12:57:12 +01:00
retoor 8f502af84e Unbuffered. 2025-01-31 12:52:56 +01:00
retoor 010f3b03a0 Unbuffered. 2025-01-31 12:52:22 +01:00
retoor bfdfa6c8bb Unbuffered. 2025-01-31 12:51:47 +01:00
retoor 0a70e80668 Unbuffered. 2025-01-31 12:49:47 +01:00
retoor cc3b896d2c Unbuffered. 2025-01-31 12:45:42 +01:00
retoor 780c178d95 Unbuffered. 2025-01-31 12:43:38 +01:00
retoor 5fd03efc30 Unbuffered. 2025-01-31 12:38:58 +01:00
retoor c6f4393166 Unbuffered. 2025-01-31 12:38:21 +01:00
retoor 312b9eeeca Unbuffered. 2025-01-31 12:34:06 +01:00
retoor 1c53a90e00 Unbuffered. 2025-01-31 12:31:22 +01:00
retoor 4de93489ef Unbuffered. 2025-01-31 12:21:53 +01:00
retoor 8eff6dd6cb Unbuffered. 2025-01-31 12:17:10 +01:00
retoor 15de277a5b drive. 2025-01-31 12:06:42 +01:00
retoor b06a10f6ec Added traceback 2025-01-31 12:06:23 +01:00
retoor 03e9003969 Update. 2025-01-29 23:20:35 +01:00
retoor 99cea506de Username support. 2025-01-29 22:15:25 +01:00
retoor a8e3ad1af9 Update. 2025-01-29 19:47:38 +01:00
retoor 5c4c579389 Update. 2025-01-29 19:17:01 +01:00
retoor 3d6e1d2e94 Update. 2025-01-29 19:13:41 +01:00
retoor 20d8d27f03 Update. 2025-01-29 18:58:38 +01:00
retoor 5b88350ff2 Update. 2025-01-29 18:56:28 +01:00
retoor bca39a612c Update. 2025-01-29 18:12:22 +01:00
retoor f69586ccf7 Deleted pyc 2025-01-29 16:19:53 +00:00
retoor 4fbfe90a13 Added docks. 2025-01-29 17:17:27 +01:00
retoor 75cb7605cd Added docks. 2025-01-29 17:15:54 +01:00
retoor 3be25285f4 Added docks. 2025-01-29 17:12:25 +01:00
retoor af399e3b72 Added docks. 2025-01-29 17:08:40 +01:00
retoor 9e89e27c66 Added docks. 2025-01-29 16:47:25 +01:00
retoor 80f1bbc05e Added docks. 2025-01-29 16:47:20 +01:00
retoor 82de0f3044 Added docks. 2025-01-29 16:42:12 +01:00
retoor b562d17167 Added docks. 2025-01-29 16:41:34 +01:00
retoor f9fed90e86 Move back 2025-01-29 16:37:16 +01:00
retoor d7c003c409 temporary move 2025-01-29 16:36:18 +01:00
retoor 561a915e30 Message 2025-01-29 16:31:06 +01:00
retoor 03c72e85f7 Markdown. 2025-01-29 16:30:54 +01:00
retoor 1f5dc57d6f Install button. 2025-01-29 06:45:26 +01:00
retoor 3e4b6b0062 Install button. 2025-01-29 06:45:14 +01:00
retoor 438fad3014 Install button. 2025-01-29 06:43:42 +01:00
retoor 030942db09 New padding. 2025-01-29 02:53:51 +01:00
retoor 4442f75ec5 New padding. 2025-01-29 02:52:53 +01:00
retoor 5f3dac8bc6 New padding. 2025-01-29 02:48:16 +01:00
retoor c558dc2d79 New padding. 2025-01-29 02:47:00 +01:00
retoor 931aae5134 New padding. 2025-01-29 02:45:18 +01:00
retoor 0e821f8b58 New padding. 2025-01-29 01:06:28 +01:00
retoor 75ec590be5 New padding. 2025-01-29 01:04:54 +01:00
retoor 41927b7ef4 New padding. 2025-01-29 00:55:17 +01:00
retoor 16afbb4e15 New padding. 2025-01-29 00:48:21 +01:00
retoor 9f652ece1b New padding. 2025-01-29 00:46:11 +01:00
retoor 93b2f6cc41 New padding. 2025-01-29 00:35:21 +01:00
retoor 284d38096c New padding. 2025-01-29 00:33:11 +01:00
retoor 84e5bac1b9 New padding. 2025-01-29 00:33:00 +01:00
retoor 9e94210bc3 New padding. 2025-01-28 21:54:53 +01:00
retoor d69c75c619 New padding. 2025-01-28 21:43:48 +01:00
retoor 4da635502b Updated manifest. 2025-01-28 21:01:51 +01:00
retoor 7d05bd9da4 Favicon. 2025-01-28 20:52:37 +01:00
retoor b2ca373081 Reconnector. 2025-01-28 20:41:24 +01:00
retoor 14c59ba5c0 Added notification sound. 2025-01-28 17:37:10 +01:00
retoor 5aee606d5d Added notification sound. 2025-01-28 17:24:10 +01:00
retoor 4f1a48c197 Added schedule. 2025-01-28 17:11:30 +01:00
retoor 99d335ac24 Added schedule. 2025-01-28 17:08:18 +01:00
retoor da72a15068 Removed pyc files. 2025-01-28 15:52:13 +01:00
retoor d93d48ef7e Added 30 limit on all tables. 2025-01-28 15:32:23 +01:00
retoor 01d8093e72 Added 30 limit on all tables. 2025-01-28 15:28:43 +01:00
retoor 8e825a90c6 Deleted pycache. 2025-01-27 05:12:02 +01:00
retoor f3d12a257e Update. 2025-01-27 03:48:53 +01:00
retoor 374db23669 Update. 2025-01-27 03:38:46 +01:00
retoor 095e30a92f Update. 2025-01-27 03:37:04 +01:00
retoor 26210f8c09 Update. 2025-01-27 03:16:44 +01:00
retoor 188a1e6178 Update. 2025-01-27 03:04:43 +01:00
retoor 2a3e225e1d Update. 2025-01-27 02:58:28 +01:00
retoor 4f71f74574 Persistance. 2025-01-27 02:57:51 +01:00
retoor aec9ffd1a1 Persistance. 2025-01-27 01:06:13 +01:00
retoor 87895a72d3 Progress. 2025-01-27 00:56:16 +01:00
retoor 36c69eb8bb Progress. 2025-01-27 00:56:06 +01:00
retoor fb7cb35921 Update. 2025-01-26 22:54:29 +01:00
retoor 4ae846cf8b Update. 2025-01-26 22:51:51 +01:00
retoor 4c601e8333 Format. 2025-01-26 22:49:37 +01:00
retoor 488afdcc74 Progress. 2025-01-26 22:48:58 +01:00
retoor f25feeeca3 Formatting. 2025-01-25 22:28:33 +01:00
retoor b4f9ff2c62 Mappers and models. 2025-01-25 22:24:44 +01:00
retoor bb6bcf41d1 Progress. 2025-01-25 05:50:23 +01:00
retoor 12ca8e4296 Format. 2025-01-25 03:47:16 +01:00
retoor 352d2deb12 Added status 2025-01-25 03:46:53 +01:00
retoor 5c69e14d7c Added session support. 2025-01-25 03:46:33 +01:00
retoor dae877113c Format. 2025-01-24 23:42:24 +01:00
retoor b56371994f Added docs. 2025-01-24 23:41:54 +01:00
retoor 9b93403a93 Formatting. 2025-01-24 23:35:44 +01:00
retoor 18b76ebd5e Progress. 2025-01-24 23:33:36 +01:00
retoor 2ba55f692d Finished register. 2025-01-24 21:19:03 +01:00
retoor be9489f939 Docs. 2025-01-24 16:43:57 +01:00
retoor aecd9f844e Docs. 2025-01-24 16:34:02 +01:00
retoor 8486c22c32 Disabled cache. 2025-01-24 16:33:27 +01:00
retoor 21ab5628b0 Caching. 2025-01-24 16:09:10 +01:00
retoor c1eeacc0b4 Caching. 2025-01-24 16:08:56 +01:00
retoor 6ba6121988 CSS. 2025-01-24 15:20:35 +01:00
retoor 757b67b78c CSS. 2025-01-24 14:47:19 +01:00
retoor bda93e354f Complete system. 2025-01-24 14:16:52 +01:00
retoor 0271e3f971 Complete system. 2025-01-24 14:15:55 +01:00
retoor 4b48485bcc Complete system. 2025-01-24 14:05:47 +01:00
retoor d20079f3ed Complete system. 2025-01-24 14:00:10 +01:00
retoor ba83922660 Progress. 2025-01-24 03:29:54 +01:00
retoor 2e3b85d7f7 Updated compose.yml 2025-01-18 12:23:23 +00:00
retoor a7446d1314 Progress. 2025-01-18 13:21:38 +01:00
retoor 46a27405ae Initial commit. 2025-01-17 23:09:46 +01:00
retoor 66f8942936 Initial commit. 2025-01-17 23:06:17 +01:00
341 changed files with 2440 additions and 33629 deletions
-3
View File
@@ -1,6 +1,3 @@
snek-container-compose.yml
.r_history
.vscode
.history
.resources
-290
View File
@@ -1,290 +0,0 @@
# 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)
+34 -4
View File
@@ -1,10 +1,40 @@
FROM python:3.14.0a6-bookworm
RUN mkdir -p /code
FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf
FROM python:3.12.8-alpine3.21
WORKDIR /code
RUN apt update && apt install build-essential docker -y
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
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
@@ -1,18 +0,0 @@
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
+5 -22
View File
@@ -6,29 +6,12 @@ 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:
.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 .
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
-21
View File
@@ -1,21 +0,0 @@
-----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-----
+17 -7
View File
@@ -2,17 +2,27 @@ 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
- 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"]
#["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
@@ -1,28 +0,0 @@
-----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-----
+3 -31
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.36.0"
version = "1.0.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
@@ -15,11 +15,8 @@ keywords = ["chat", "snek", "molodetz"]
requires-python = ">=3.12"
dependencies = [
"mkdocs>=1.4.0",
"lxml",
"IPython",
"shed",
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
"app @ git+https://retoor.molodetz.nl/retoor/app",
"beautifulsoup4",
"gunicorn",
"imgkit",
@@ -31,31 +28,6 @@ dependencies = [
"asyncssh",
"emoji",
"aiofiles",
"PyJWT",
"multiavatar",
"gitpython",
'uvloop; platform_system != "Windows"',
"humanize",
"Pillow",
"pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk",
"bcrypt"
"PyJWT"
]
[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
@@ -1,15 +0,0 @@
[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
@@ -1,55 +0,0 @@
# Project Quality Review: Snek
## Overview
The Snek project is a comprehensive web-based application, functioning as a collaborative platform or chat system with extensive features including user management, channels, repositories, containers, real-time communication via WebSockets, Docker integration, Git support, and more. It leverages Python (primarily with aiohttp for the backend) and JavaScript for the frontend. The codebase is organized into modules such as app, mapper, model, service, view, system, and static assets. This review is based on a literal examination of every Python file in `src/snek/*` recursively, as their full contents have been provided.
## Strengths
- **Modular Architecture**: The code exhibits strong separation of concerns with dedicated modules for mappers (data access), models (data structures), services (business logic), views (HTTP/WebSocket handlers), and system utilities. This promotes maintainability and scalability.
- **Asynchronous Support**: Extensive use of async/await throughout (e.g., in `src/snek/app.py`, `src/snek/service/socket.py`, `src/snek/view/rpc.py`), effectively handling I/O-bound operations like WebSockets, database queries, and external API calls.
- **Feature-Rich**: Supports a wide array of features, including user authentication, file uploads, terminal emulation, Docker container management, Git repositories, WebDAV, SSH, forums, push notifications, and avatar generation. Integration with external tools like Docker and Git is notable.
- **Frontend Components**: Custom JavaScript components (e.g., in `src/snek/static/njet.js`) provide a modern, component-based UI with event handling and REST clients.
- **Caching and Utilities**: Robust caching implementation (e.g., in `src/snek/system/cache.py`) with LRU eviction, and utility services (e.g., `src/snek/service/util.py`) enhance performance and reusability.
- **Model and Field System**: A sophisticated model system (e.g., in `src/snek/system/model.py`) with typed fields (e.g., UUIDField, CreatedField) ensures data integrity and validation.
- **Extensibility**: The codebase includes hooks for extensions (e.g., Jinja2 extensions in `src/snek/system/markdown.py`, `src/snek/system/template.py`) and sub-applications (e.g., forum in `src/snek/forum.py`).
- **Error Handling in Places**: Some areas show good error handling (e.g., try-except in `src/snek/service/socket.py`, `src/snek/view/rpc.py`), though not universal.
## Weaknesses
- **Syntax Errors**:
- In `src/snek/research/serpentarium.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` is misspelled; it should be `setattr`. Additionally, the line ends with `)`, which is misplaced and causes a syntax error. Line 26: `self.setattr(self, "db", self.set)` – similar misspelling and incorrect assignment (self.set is not defined).
- In `src/snek/sync.py`, line 25: `self.setattr(self, "db", self.get)` – `setattr` misspelled and misplaced `)`. Line 26: `self.setattr(self, "db", self.set)` – same issues. Line 27: `super()` – called without arguments, which may fail in classes with multiple inheritance or if arguments are expected.
- **Security Concerns**:
- **Injection Vulnerabilities**: Raw SQL queries without parameterization (e.g., in `src/snek/service/channel.py`, `src/snek/service/user.py`, `src/snek/view/rpc.py`) risk SQL injection. For example, in `src/snek/service/channel.py`, queries like `f"SELECT ... WHERE channel_uid=:channel_uid {history_start_filter}"` use string formatting, but not all are parameterized.
- **Authentication Gaps**: Some WebSocket and RPC endpoints lack visible authentication checks (e.g., in `src/snek/view/rpc.py`, methods like `echo` and `query` don't enforce login). Basic auth in `src/snek/webdav.py` and `src/snek/sssh.py` is present but hardcoded.
- **Input Validation**: Minimal input sanitization (e.g., in `src/snek/system/form.py`, forms have placeholders but no regex or length checks enforced). User inputs in RPC calls (e.g., `src/snek/view/rpc.py`) are not validated.
- **Hardcoded Secrets**: Database paths (e.g., 'sqlite:///snek.db' in multiple files), keys (e.g., SESSION_KEY in `src/snek/app.py`), and credentials are hardcoded, posing risks if exposed.
- **Privilege Escalation**: Admin checks are inconsistent (e.g., in `src/snek/service/channel.py`, `src/snek/view/rpc.py`), and some operations (e.g., clearing channels) only check `is_admin` without further validation.
- **WebDAV and SSH**: `src/snek/webdav.py` and `src/snek/sssh.py` handle file access but lack rate limiting or detailed permission checks.
- **Code Quality Issues**:
- **Inconsistent Naming and Style**: Mixed camelCase and snake_case (e.g., `set_typing` vs. `generateUniqueId` in JS). Some classes have similar names (e.g., `DatasetWebSocketView` in research files).
- **Lack of Documentation**: Few docstrings or comments (e.g., no docstrings in `src/snek/service/user.py`, `src/snek/view/channel.py`). Methods are often self-explanatory but lack context.
- **Hardcoded Values**: URLs, paths, and constants are hardcoded (e.g., in `src/snek/static/njet.js`, `src/snek/app.py`).
- **Global State and Side Effects**: Use of global variables (e.g., in `src/snek/system/markdown.py`, `src/snek/view/rpc.py`) and mutable defaults can lead to bugs.
- **Performance Issues**: No visible optimization for large datasets (e.g., in `src/snek/service/channel_message.py`, queries could be inefficient). Process pools in `src/snek/service/channel_message.py` are per-message, potentially wasteful.
- **Error Handling Gaps**: Many methods lack try-except (e.g., in `src/snek/system/docker.py`, `src/snek/system/terminal.py`). Exceptions are sometimes caught but not logged properly.
- **Dependencies**: Imports like `dataset`, `git`, `pymongo` (implied) are not version-pinned, risking compatibility issues.
- **Testing Absence**: No visible unit or integration tests in the codebase.
- **Potential Bugs**:
- In `src/snek/research/serpentarium.py`, `self.setattr(self, "db", self.set)` assigns undefined `self.set`.
- In `src/snek/sync.py`, similar assignment issues.
- In `src/snek/view/rpc.py`, `self.user_uid` property assumes `self.view.session.get("uid")`, but no null checks.
- In `src/snek/system/docker.py`, `ComposeFileManager` uses subprocess without full error handling.
- In `src/snek/service/channel_message.py`, executor pools per UID could lead to resource leaks if not cleaned up.
- In `src/snek/forum.py`, event listeners are added but no removal logic is visible.
- **Maintainability**: Large files (e.g., `src/snek/view/rpc.py` is over 1000 lines) and complex methods (e.g., in `src/snek/app.py`) make refactoring hard. Some code duplication (e.g., WebSocket handling in multiple views).
## Recommendations
- **Fix Syntax Errors Immediately**: Correct `setattr` spellings, remove misplaced `)`, and fix `super()` calls in `src/snek/research/serpentarium.py` and `src/snek/sync.py` to prevent runtime failures.
- **Enhance Security**: Implement parameterized queries, add input validation (e.g., using regex in `src/snek/system/form.py`), enforce authentication in all endpoints, and use environment variables for secrets.
- **Improve Code Quality**: Add docstrings, comments, and consistent naming. Refactor large methods and remove hardcoded values.
- **Add Error Handling and Testing**: Wrap risky operations in try-except, log errors, and introduce unit tests (e.g., using pytest).
- **Optimize Performance**: Review query efficiency, add indexing, and manage resources (e.g., executor pools).
- **Address Bugs**: Fix undefined assignments and add null checks.
- **General**: Pin dependencies, review for race conditions in async code, and consider code reviews or linters (e.g., flake8, mypy).
## Grade
B- (Solid foundation with good architecture and features, but critical syntax errors, security vulnerabilities, and quality issues require immediate attention to avoid production risks.)
Binary file not shown.
-71
View File
@@ -1,71 +0,0 @@
# 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
@@ -1,148 +0,0 @@
# 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()
+24 -471
View File
@@ -1,17 +1,5 @@
# 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 IP2Location
from aiohttp import web
from aiohttp_session import (
get_session as session_get,
@@ -20,99 +8,26 @@ from aiohttp_session import (
)
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
from snek import snode
from snek.docs.app import Application as DocsApplication
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 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.system.middleware import cors_middleware
from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension
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.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
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__)
from snek.view.upload import UploadView
from snek.view.search_user import SearchUserView
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
@@ -124,186 +39,30 @@ async def session_middleware(request, handler):
return response
@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):
def __init__(self, *args, **kwargs):
middlewares = [
cors_middleware,
csp_middleware,
web.normalize_path_middleware(merge_slashes=True),
]
self._test_db = db_connection
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5,
**kwargs,
middlewares=middlewares, template_path=self.template_path, *args, **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.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())
self.cache = Cache(self)
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
def setup_router(self):
self.router.add_get("/", IndexView)
@@ -313,117 +72,34 @@ 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}", 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_get("/c/{channel:.*}", ChannelView)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
self.add_subapp(
"/docs",
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
)
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 whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
return await self.render_template(
"test.html", request, context={"name": "retoor"}
)
async def handle_http_get(self, request: web.Request):
@@ -438,136 +114,13 @@ class Application(BaseApplication):
body=path.read_bytes(), headers={"Content-Type": "image/png"}
)
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
start_time = time.perf_counter()
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
context["config"] = self.config
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
return await super().render_template(template, request, context)
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():
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())
web.run_app(app, port=8081, host="0.0.0.0")
-128
View File
@@ -1,128 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import pathlib
from aiohttp import web
-41
View File
@@ -1,41 +0,0 @@
# 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
@@ -1,3 +0,0 @@
# retoor <retoor@molodetz.nl>
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
+1 -2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
@@ -18,3 +16,4 @@ class SearchUserForm(Form):
action = FormButtonElement(
name="action", value="submit", text="Search", type="button"
)
-27
View File
@@ -1,27 +0,0 @@
# 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
@@ -1,95 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.app import app
application = app
+2 -24
View File
@@ -1,22 +1,12 @@
# 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.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.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper
from snek.system.object import Object
@@ -31,21 +21,9 @@ 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel import ChannelModel
from snek.system.mapper import BaseMapper
-9
View File
@@ -1,9 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper
-9
View File
@@ -1,9 +0,0 @@
# 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"
+2 -4
View File
@@ -1,9 +1,7 @@
# 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
+3 -6
View File
@@ -1,10 +1,7 @@
# 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
@@ -1,25 +0,0 @@
# 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
@@ -1,9 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper
-11
View File
@@ -1,11 +0,0 @@
# 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
@@ -1,9 +0,0 @@
# 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
@@ -1,9 +0,0 @@
# 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,26 +1,7 @@
# 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
@@ -1,9 +0,0 @@
# 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,22 +1,11 @@
# 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
@@ -28,18 +17,6 @@ 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,6 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
@@ -12,28 +9,3 @@ 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
@@ -1,17 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@@ -15,29 +13,3 @@ 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,8 +1,3 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
@@ -11,17 +6,3 @@ 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
@@ -1,13 +0,0 @@
# 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)
+2 -11
View File
@@ -1,16 +1,7 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
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
+6 -22
View File
@@ -1,25 +1,9 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.model import BaseModel, ModelField
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)
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
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)
-101
View File
@@ -1,101 +0,0 @@
# 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
@@ -1,11 +0,0 @@
# 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,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-14
View File
@@ -1,14 +0,0 @@
# 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
@@ -1,10 +0,0 @@
# 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
@@ -1,14 +0,0 @@
# 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)
+4 -43
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@@ -20,7 +18,10 @@ 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",
@@ -28,43 +29,3 @@ class UserModel(BaseModel):
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
)
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
@@ -1,9 +0,0 @@
# 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
@@ -1,58 +0,0 @@
# 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
@@ -1,56 +0,0 @@
# 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
@@ -1,149 +0,0 @@
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
@@ -1,54 +0,0 @@
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);
}
}
});
+14 -52
View File
@@ -1,73 +1,35 @@
# 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.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.service.drive import DriveService
from snek.service.drive_item import DriveItemService
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):
result = Object(
return Object(
**{
name: service_cls(app=app)
for name, service_cls in _service_registry.items()
"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)
}
)
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
@@ -1,577 +0,0 @@
# 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"}
-92
View File
@@ -1,48 +1,9 @@
# retoor <retoor@molodetz.nl>
import pathlib
from datetime import datetime
from snek.system.model import now
from snek.system.service import BaseService
class ChannelService(BaseService):
mapper_name = "channel"
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
result = await super().get(**kwargs)
if result:
return result
kwargs["name"] = "#" + uid
result = await super().get(**kwargs)
if result:
return result
return None
return await super().get(**kwargs)
async def create(
self,
label,
@@ -65,62 +26,9 @@ 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)
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
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):
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"])
if user:
yield user
async def get_online_users(self, channel_uid):
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() < 180:
yield user
async def get_for_user(self, user_uid):
async for channel_member in self.services.channel_member.find(
user_uid=user_uid,
is_banned=False,
deleted_at=None,
):
channel = await self.get(uid=channel_member["channel_uid"])
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")
is_moderator = False
-27
View File
@@ -1,27 +0,0 @@
# 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}.")
+2 -45
View File
@@ -1,6 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import now
from snek.system.service import BaseService
@@ -8,19 +5,6 @@ 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,
@@ -32,7 +16,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.value:
return False
return model
model = await self.new()
@@ -44,34 +28,7 @@ class ChannelMemberService(BaseService):
model["is_read_only"] = is_read_only
model["is_muted"] = is_muted
model["is_banned"] = is_banned
print(model.record, flush=True)
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 ",
{"from_user": from_user, "to_user": to_user},
):
return model
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"])
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"]:
return await self.services.user.get(uid=model["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
+23 -198
View File
@@ -1,212 +1,37 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import pathlib
from concurrent.futures import ProcessPoolExecutor
from snek.system.service import BaseService
from snek.system.template import sanitize_html
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))
import jinja2
class ChannelMessageService(BaseService):
mapper_name = "channel_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}")
async def create(self, channel_uid, user_uid, message):
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model["is_final"] = is_final
context = {}
record = model.record
context = {
}
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.error(f"create: user not found user_uid={user_uid}")
raise Exception("User not found")
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
loop = asyncio.get_event_loop()
context.update(dict(
user_uid=user['uid'],
username=user['username'],
user_nick=user['nick']
))
try:
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)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
except Exception as ex:
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)
print(ex,flush=True)
return {
"uid": message["uid"],
"color": user["color"],
"user_uid": message["user_uid"],
"channel_uid": message["channel_uid"],
"user_nick": user["nick"],
"message": message["message"],
"created_at": message["created_at"],
"html": message["html"],
"username": user["username"],
}
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
try:
if 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(
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(
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 Exception as ex:
logger.error(f"offset query failed: {ex}")
results.sort(key=lambda x: x["created_at"])
return results
if await self.save(model):
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
+21 -108
View File
@@ -1,118 +1,31 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class ChatService(BaseService):
async def finalize(self, message_uid):
logger.info(f"finalize: starting for message_uid={message_uid}")
channel_message = await self.services.channel_message.get(uid=message_uid)
if not channel_message:
logger.warning(f"finalize: message not found uid={message_uid}")
return
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
logger.debug(f"finalize: message marked as final uid={message_uid}")
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
await self.services.socket.broadcast(
channel["uid"],
{
"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"],
},
async def send(self,user_uid, channel_uid, message):
channel_message = await self.services.channel_message.create(
channel_uid,
user_uid,
message
)
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)
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
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
-120
View File
@@ -1,120 +0,0 @@
# 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
@@ -1,68 +0,0 @@
# 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)
+14 -148
View File
@@ -1,155 +1,21 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
class DriveService(BaseService):
mapper_name = "drive"
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_by_user(self, user_uid):
drives = []
async for model in self.find(user_uid=user_uid):
drives.append(model)
return drives
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"])
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]
+9 -13
View File
@@ -1,22 +1,18 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
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["extension"] = str(name).split(".")[-1]
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['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
@@ -1,310 +0,0 @@
# 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
@@ -1,139 +0,0 @@
# 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
+3 -56
View File
@@ -1,31 +1,9 @@
# 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
@@ -40,25 +18,13 @@ 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"
@@ -66,25 +32,6 @@ class NotificationService(BaseService):
model["message"] = (
f"New message from {user['nick']} in {channel_member['label']}."
)
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()
if await self.save(model):
return model
raise Exception(f"Failed to create notification: {model.errors}.")
-87
View File
@@ -1,87 +0,0 @@
# 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
@@ -1,269 +0,0 @@
# 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
@@ -1,65 +0,0 @@
# 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)
+24 -358
View File
@@ -1,374 +1,40 @@
# 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):
class Socket:
def __init__(self, ws, user: UserModel):
self.ws = ws
self.is_connected = True
self.user = user
self.user_uid = safe_get(user, "uid") if user else None
self.user_color = safe_get(user, "color") if user else None
self.subscribed_channels = set()
async def send_json(self, data):
if data is None:
return False
if not self.is_connected:
return False
if not self.ws:
self.is_connected = False
return False
try:
await self.ws.send_json(data)
return True
except ConnectionResetError:
self.is_connected = False
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
self.is_connected = False
self.subscribed_channels.clear()
try:
if self.ws and not self.ws.closed:
await asyncio.wait_for(self.ws.close(), timeout=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 add(self, ws):
self.sockets.add(ws)
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):
if not ws:
return None
if not user_uid:
return None
try:
if not self.app or not hasattr(self.app, "services") or not self.app.services:
logger.warning("Services not available for socket add")
return None
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.warning(f"User not found for socket add: {user_uid}")
return None
s = self.Socket(ws, user)
username = safe_get(user, "username", "unknown")
nick = safe_get(user, "nick") or username
color = s.user_color
async with self._lock:
self.sockets.add(s)
is_first_connection = False
if user_uid not in self.users:
self.users[user_uid] = set()
is_first_connection = True
elif len(self.users[user_uid]) == 0:
is_first_connection = True
self.users[user_uid].add(s)
try:
fresh_user = await self.app.services.user.get(uid=user_uid)
if fresh_user:
fresh_user["last_ping"] = now()
await self.app.services.user.save(fresh_user)
except Exception as ex:
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
logger.info(f"Added socket for user {username}")
if is_first_connection:
await self._broadcast_presence("arrived", user_uid, nick, color)
return s
except Exception as ex:
logger.warning(f"Failed to add socket: {safe_str(ex)}")
return None
async def subscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
existing_socket = None
user_sockets = self.users.get(user_uid, set())
for sock in user_sockets:
if sock and sock.ws == ws:
existing_socket = sock
break
if not existing_socket:
return False
existing_socket.subscribed_channels.add(channel_uid)
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(user_uid)
return True
except Exception as ex:
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
return False
async def unsubscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
for s in self.sockets:
if s and s.ws == ws:
s.subscribed_channels.discard(channel_uid)
break
return True
except Exception as ex:
logger.warning(f"Failed to unsubscribe: {safe_str(ex)}")
return False
async def send_to_user(self, user_uid, message):
if not user_uid or message is None:
return 0
count = 0
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 subscribe(self, ws, channel_uid):
if not channel_uid in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(ws)
async def broadcast(self, channel_uid, message):
if not channel_uid or message is None:
logger.debug(f"broadcast: invalid params channel_uid={channel_uid}, message={message is not None}")
return False
logger.debug(f"broadcast: starting for channel={channel_uid}")
return await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
if not channel_uid or message is None:
return False
sent = 0
user_uids_to_send = set()
try:
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
print("BROADCAT!",message)
count = 0
subscriptions = set(self.subscriptions.get(channel_uid,[]))
for ws in subscriptions:
try:
await ws.send_json(message)
except Exception as ex:
print(ex,flush=True)
print("Deleting socket.",flush=True)
self.subscriptions[channel_uid].remove(ws)
continue
count += 1
return count
async def delete(self, ws):
if not ws:
return
async with self._lock:
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
for s in sockets_to_remove:
self.sockets.discard(s)
departures_to_broadcast = []
channels_to_cleanup = set()
for s in sockets_to_remove:
user_uid = s.user_uid
if not user_uid:
continue
is_last_connection = False
if user_uid in self.users:
self.users[user_uid].discard(s)
if len(self.users[user_uid]) == 0:
del self.users[user_uid]
is_last_connection = True
if is_last_connection:
user_nick = None
try:
if s.user:
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
except Exception:
pass
if user_nick:
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
for channel_uid in list(s.subscribed_channels):
channels_to_cleanup.add((channel_uid, user_uid))
for channel_uid, user_uid in channels_to_cleanup:
try:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
except Exception as ex:
logger.debug(f"Failed to cleanup channel subscription: {safe_str(ex)}")
for s in sockets_to_remove:
try:
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
logger.info(f"Removed socket for user {username}")
await s.close()
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
self.sockets.remove(ws)
except :
pass
-95
View File
@@ -1,95 +0,0 @@
# 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()
+6 -74
View File
@@ -1,24 +1,16 @@
# 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:
return []
raise []
results = []
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):
results.append(result)
return results
@@ -31,79 +23,19 @@ 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
@@ -1,38 +0,0 @@
# 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
+5 -6
View File
@@ -1,16 +1,15 @@
# 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 f"#{r:02x}{g:02x}{b:02x}"
return "#{:02x}{:02x}{:02x}".format(r, g, b)
-671
View File
@@ -1,671 +0,0 @@
# 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
@@ -1,21 +0,0 @@
# 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
@@ -1,127 +0,0 @@
# 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
@@ -1,83 +0,0 @@
# 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
@@ -1,696 +0,0 @@
/* 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%;
}
}
+305 -251
View File
@@ -1,263 +1,317 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient {
debug = false;
// 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.
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;
}
// No additional imports were used beyond standard JavaScript objects and constructors.
async post(url, data) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
// MIT License
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
}
}
class RESTClient {
debug = false;
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;
}
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,
});
}
}
export class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
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);
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',
},
});
});
}
}
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)
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
});
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`;
return result;
}
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}`);
});
}),
);
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;
}
}
}
export const app = new App();
window.app = app;
class EventHandler {
constructor() {
this.subscribers = {};
}
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() {
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..");
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.connectPromises.forEach(resolver => resolver(this));
};
});
}
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);
}
}
async sendJson(data) {
await this.connect().then(api => {
api.ws.send(JSON.stringify(data));
});
}
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);
});
}
onClose() {
console.info("Connection lost. Reconnecting.");
this.isConnected = false;
this.isConnecting = false;
this.ws.close();
this.ws = null;
this.ensureConnection().then(() => {
console.info("Reconnected.");
});
}
}
class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
sounds = ["/audio/soundfx.d_beep3.mp3"];
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 = {};
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
const me = this
this.ws.addEventListener("channel-message", (data) => {
me.emit(data.channel_uid, data);
});
this.rpc.getUser(null).then(user => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
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();
Binary file not shown.
-18
View File
@@ -1,18 +0,0 @@
.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;
}
}
+169 -742
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
background: #1a1a1a;
color: #e6e6e6;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
.btn:active {
background: #111;
transform: translateY(1px);
}
.btn:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
border-color: #222;
}
.btn-primary {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
.btn-primary:hover {
background: #e04924;
border-color: #e04924;
}
.btn-secondary {
background: #2a2a2a;
border-color: #444;
}
.btn-secondary:hover {
background: #3a3a3a;
border-color: #555;
}
.btn-danger {
background: #1a1a1a;
border-color: #8b0000;
color: #ff6b6b;
}
.btn-danger:hover {
background: #2a1515;
border-color: #b00;
}
.btn-success {
background: #1a1a1a;
border-color: #006400;
color: #6bff6b;
}
.btn-success:hover {
background: #152a15;
border-color: #0b0;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 14px 28px;
font-size: 16px;
}
.btn-block {
display: flex;
width: 100%;
}
-123
View File
@@ -1,123 +0,0 @@
/* 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
@@ -1,171 +0,0 @@
// 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);
+45 -555
View File
@@ -1,579 +1,69 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
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";
// 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.
const log = loggerFactory.getLogger("ChatInput");
// Includes standard DOM manipulation methods; no external imports used.
class ChatInputComponent extends NjetComponent {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
};
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
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;
class ChatInputElement extends HTMLElement {
_chatWindow = null
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.textarea.classList.add("chat-input-textarea");
this.value = this.getAttribute("value") || "";
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
}
set chatWindow(value){
this._chatWindow = value
get value() {
return this._value;
}
set value(value) {
this._value = value;
this.textarea.value = this._value;
get chatWindow(){
return this._chatWindow
}
get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions);
get channelUid() {
return this.chatWindow.channel.uid
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
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,
};
});
}
isFuzzyMatch(needle, haystack) {
if (needle.length < 2) return false;
let needleIndex = 0;
for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
if (haystack[i] === needle[needleIndex]) {
needleIndex++;
}
}
return needleIndex === needle.length;
}
isCloseMatch(str1, str2) {
if (Math.abs(str1.length - str2.length) > 2) return false;
const shorter = str1.length <= str2.length ? str1 : str2;
const longer = str1.length > str2.length ? str1 : str2;
let differences = 0;
let j = 0;
for (let i = 0; i < shorter.length && j < longer.length; i++) {
if (shorter[i] !== longer[j]) {
differences++;
if (j + 1 < longer.length && shorter[i] === longer[j + 1]) {
j++;
}
}
j++;
}
differences += Math.abs(longer.length - j);
return differences <= 2;
}
matchMentions4ToAuthors(mentions, authors) {
return mentions.map((mention) => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach((author) => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10;
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
if (minDistance < 5) {
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + 1
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
if(closestAuthor){
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
}
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;
});
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) => {
this.textBox.addEventListener('change', (e) => {
e.preventDefault();
this.fileUploadGrid.openFileDialog();
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
console.error(e.target.value);
});
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") {
this.textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
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);
const message = e.target.value.trim();
if (!message) return;
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
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;
}
this.component.appendChild(this.container);
}
}
customElements.define("chat-input", ChatInputComponent);
customElements.define('chat-input', ChatInputElement);

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