Compare commits

...

300 Commits

Author SHA1 Message Date
2fc18801a7 Merge pull request 'Fix heif converter being broken' () from BordedDev/snek:bugfix/fix-pictures-again into main
Reviewed-on: 
2025-06-10 05:40:52 +02:00
BordedDev
f0d2a7cc05
Register HEIF opener and handle terminal import error gracefully 2025-06-09 22:03:36 +02:00
2808952153 Env file. 2025-06-09 15:04:52 +02:00
149659064d Update. 2025-06-09 06:55:37 +02:00
cb310967cd Update. 2025-06-09 06:47:50 +02:00
3e7cb9387c Update. 2025-06-09 06:46:43 +02:00
cef451cb17 Fix broken. 2025-06-08 20:39:36 +02:00
da9566c11f Update. 2025-06-08 20:36:45 +02:00
d3c0e138d8 Update. 2025-06-08 18:54:22 +02:00
7afc24ce51 Update. 2025-06-08 18:54:02 +02:00
92573ebeb4 Update. 2025-06-08 18:49:47 +02:00
987bd3a1c7 Removed version. 2025-06-08 12:58:50 +02:00
a95a09a062 Update terminal. 2025-06-08 11:59:16 +02:00
7b2c93bcef Update. 2025-06-08 11:41:25 +02:00
f02058b0c0 Update. 2025-06-08 03:59:50 +02:00
f35742fec3 Update. 2025-06-08 03:58:38 +02:00
7b08e6a45e Update Containers. 2025-06-07 20:30:16 +02:00
e99cceaa52 Perfect. 2025-06-07 08:55:09 +02:00
df8c3f1e09 Perfect. 2025-06-07 08:52:08 +02:00
b65ec449a0 Update. 2025-06-07 08:31:23 +02:00
bb9d763416 Update. 2025-06-07 08:27:10 +02:00
bdddbf678c Update. 2025-06-07 07:56:57 +02:00
389d417c1b Update. 2025-06-07 05:47:54 +02:00
f182c2209e Update. 2025-06-07 05:46:30 +02:00
8e9ee4bff0 Update. 2025-06-06 16:10:48 +02:00
7f47a21d40 Update. 2025-06-06 16:03:16 +02:00
7914511de5 Update. 2025-06-06 15:57:36 +02:00
efbc6a9b4c Update. 2025-06-06 15:08:34 +02:00
57ac8e772b Update. 2025-06-06 15:02:35 +02:00
3589f42651 Update manifest. 2025-06-06 14:58:53 +02:00
1052010dd5 New sneklogo's. 2025-06-06 14:58:53 +02:00
ae26181cf8 New logo's new sizes markdown stripper.py 2025-06-06 14:58:53 +02:00
66b36509d2 Merge pull request 'Remove redundant check for 'encoding' in push notification body' () from BordedDev/snek:bugfix/removed-encoding into main
Reviewed-on: 
2025-06-06 12:09:37 +02:00
BordedDev
e49062a9db
Remove redundant check for 'encoding' in push notification body 2025-06-06 12:06:43 +02:00
9e56ff8494 Made FA locally. 2025-06-06 11:35:56 +02:00
831b5c17cd Merge pull request 'feat/push-notifications' () from BordedDev/snek:feat/push-notifications into main
Reviewed-on: 
2025-06-06 11:25:09 +02:00
BordedDev
d04ea8549d
Merge branch 'main' into feat/push-notifications
# Conflicts:
#	src/snek/app.py
2025-06-06 11:21:46 +02:00
35786703d5 Merge pull request 'Help relieve issues where network is maybe too slow?' () from BordedDev/snek:bugfix/msg-finalized-before-update into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-06 11:20:17 +02:00
10eec5fd6d Update ntsh 2025-06-06 03:46:19 +02:00
d4debeab74 Update. 2025-06-06 03:45:07 +02:00
5c0ea360cd Update. 2025-06-06 03:41:39 +02:00
3b38e30df1 Update. 2025-06-06 03:41:06 +02:00
9a39bedd3a Update. 2025-06-06 03:39:33 +02:00
9e1eb9f1e5 Update. 2025-06-06 03:36:42 +02:00
82f8a1ef4a Update. 2025-06-06 03:35:31 +02:00
7c815898ea Update. 2025-06-06 03:34:47 +02:00
58a951eec9 Update. 2025-06-06 03:33:45 +02:00
ef75cb3341 MAde elements forbidden. 2025-06-06 03:28:05 +02:00
1c71c0016b Update. 2025-06-06 03:22:39 +02:00
1a034041ab Update Security. 2025-06-06 03:04:37 +02:00
c60f9ff4d3 Update. 2025-06-06 02:34:32 +02:00
3efe388d3f Fixed escape. 2025-06-06 02:22:03 +02:00
7dc12c9e7f Update flag. 2025-06-06 02:10:28 +02:00
19c88d786e Merge pull request 'Add styles for spoiler message functionality' () from BordedDev/snek:feat/spoilers into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-06 01:32:07 +02:00
BordedDev
9937f532ec
Add styles for spoiler message functionality 2025-06-06 01:22:40 +02:00
e380a1b9e7 Merge branch 'main' into feat/push-notifications 2025-06-05 22:06:54 +02:00
BordedDev
13476bddf6
Help relieve issues where network is maybe too slow?
Also polished message handling a little
2025-06-05 19:41:43 +02:00
31d08ec973 Merge pull request 'Refactored message logic to fix issues where they desync' () from BordedDev/snek:bugfix/typing-desync into main
Reviewed-on: 
2025-06-01 22:07:51 +02:00
BordedDev
deaa7716a2
Removed some dead code 2025-06-01 20:57:28 +02:00
BordedDev
157493b0f4
Simplified some code 2025-06-01 20:56:24 +02:00
BordedDev
f7e1708039
Compacted code 2025-06-01 20:53:02 +02:00
BordedDev
20f817506f
Refactored message logic 2025-06-01 20:48:17 +02:00
BordedDev
94b9d2c63b
Added user check to not notify user sending message 2025-06-01 13:03:47 +02:00
BordedDev
fcc2d7b748
Merge branch 'main' into feat/push-notifications
# Conflicts:
#	src/snek/app.py
2025-06-01 12:47:03 +02:00
24ddd4b294 Merge pull request 'Fix image zoom URL handling to remove width and height parameters instead of all search params' () from BordedDev/snek:bugfix/fix-image-zoom-url into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-01 12:05:35 +02:00
557b34b71a Update. 2025-06-01 09:42:06 +02:00
e0255b28ec Update. 2025-06-01 03:38:12 +02:00
69855fa118 Update. 2025-06-01 03:33:58 +02:00
a07f2680d6 Update. 2025-06-01 03:24:14 +02:00
BordedDev
0738b1ff91
Removed old comment 2025-06-01 00:56:39 +02:00
388f8bc508 Merge branch 'main' into feat/push-notifications 2025-06-01 00:54:17 +02:00
a17bdc7e13 Merge branch 'main' into bugfix/fix-image-zoom-url 2025-06-01 00:53:17 +02:00
BordedDev
5711618e6e
Fix image zoom URL handling to remove width and height parameters instead of all search params 2025-06-01 00:52:02 +02:00
d022cff499 Update. 2025-06-01 00:39:53 +02:00
d4a480b5ea Update. 2025-06-01 00:38:22 +02:00
161ff392d7 Update. 2025-06-01 00:33:47 +02:00
4e72fbf84b Merge pull request 'Make database asnyc.' () from feat/make-database-async into main
Reviewed-on: 
2025-06-01 00:28:34 +02:00
bde3819510 Update. 2025-06-01 00:24:45 +02:00
097889ba3f Make database asnyc. 2025-05-31 23:30:41 +02:00
BordedDev
20dd16734f
Cleaned up push register handler 2025-05-31 23:29:45 +02:00
BordedDev
b01665f02c
Added server (debug/testing) certs 2025-05-31 23:09:56 +02:00
BordedDev
aec2da11f2
Implement push notification service and registration 2025-05-31 19:08:55 +02:00
BordedDev
272998f757
Updated conditional check 2025-05-31 15:28:54 +02:00
BordedDev
744d0ace84
Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev
326c549670
Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev
d966c9529b
Cleaned up code a bit 2025-05-31 15:28:52 +02:00
BordedDev
4350714534
Added body to push notifications 2025-05-31 15:26:46 +02:00
BordedDev
1a26cacb66
Fix up for push notifications on chrome 2025-05-31 15:26:45 +02:00
BordedDev
0057792802
Initial setup for push notifications (still has issues with fcm aka chrome/opera)
# Conflicts:
#	src/snek/templates/app.html

# Conflicts:
#	src/snek/app.py

# Conflicts:
#	src/snek/app.py
#	src/snek/templates/app.html

# Conflicts:
#	src/snek/app.py

# Conflicts:
#	src/snek/app.py
#	src/snek/static/push.js
#	src/snek/static/service-worker.js
#	src/snek/templates/app.html
2025-05-31 15:26:28 +02:00
4854d40508 Update. 2025-05-31 13:52:04 +02:00
7dd3133475 Merge pull request 'Add URL embedding functionality with metadata extraction and responsive design' () from BordedDev/snek:feat/url-embedding into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-30 19:53:22 +02:00
24dfa39f91 Update chat input. 2025-05-30 19:32:18 +02:00
BordedDev
7ec65f7c12
Cleaned up imports 2025-05-30 02:14:45 +02:00
BordedDev
4f8edef42b
Add URL embedding functionality with metadata extraction and responsive design 2025-05-30 02:13:36 +02:00
7818410d55 Upddated interval. 2025-05-29 02:42:31 +02:00
1762191b03 Update. 2025-05-28 21:49:30 +02:00
2df92e809e Added iinput mode. 2025-05-28 13:40:12 +02:00
59a8d32e40 Added iinput mode. 2025-05-28 13:36:43 +02:00
c3b3963760 Fix cross typing. 2025-05-28 12:31:17 +02:00
a0cd39e3bc Fix cross typing. 2025-05-28 12:29:01 +02:00
e48b2258e0 Made live typing default. 2025-05-28 11:38:16 +02:00
35aaf8824f Fixed directory does not exist bug. 2025-05-28 10:40:05 +02:00
76c69ca3ec Is finalized 2025-05-27 23:14:06 +02:00
9994225911 Update sort. 2025-05-27 22:22:32 +02:00
03f699e448 Updated online users. 2025-05-27 22:16:41 +02:00
2fd01a5ab7 Stars update. 2025-05-27 14:28:07 +02:00
96629113f1 Most recent users. 2025-05-27 14:03:01 +02:00
973afa0cc2 Scrolled to bottom fix. 2025-05-27 12:33:04 +02:00
9bc55e771a Scrolled to bottom fix. 2025-05-27 12:30:42 +02:00
40a292d05e Scrolled to bottom fix. 2025-05-27 12:21:40 +02:00
bf723db2cc Scrolled to bottom fix. 2025-05-27 12:16:47 +02:00
46052172b2 Scrolled to bottom fix. 2025-05-27 12:14:02 +02:00
4f777f0003 Scrolled to bottom fix. 2025-05-27 12:12:03 +02:00
b5e1ba72d0 Scrolled to bottom fix. 2025-05-27 12:10:26 +02:00
df120098f9 Scrolled to bottom fix. 2025-05-27 12:08:53 +02:00
1c1d578db7 Fixed mentions. 2025-05-27 12:06:35 +02:00
69352fe0b5 Fixed mentions. 2025-05-27 11:45:23 +02:00
fb3980dad0 Removed last-message. 2025-05-27 11:03:25 +02:00
6c21a1e619 Async load users. 2025-05-27 10:57:18 +02:00
112c0dc70a Update channel message. 2025-05-27 10:50:09 +02:00
538a9ce25d Scrolled to bottom fix. 2025-05-27 10:43:44 +02:00
cdc3d10df5 Moved method. 2025-05-27 10:33:00 +02:00
1b150e3e64 Moved method. 2025-05-27 10:33:00 +02:00
27dccc324a Moved method. 2025-05-27 10:33:00 +02:00
1bb68ab33b Moved method. 2025-05-27 10:33:00 +02:00
3c6ea15d47 Update render to include user info. 2025-05-27 10:33:00 +02:00
36e663e1ed added animal view. 2025-05-27 10:33:00 +02:00
8d2e0381a7 Merge pull request 'Enhance mobile responsiveness by updating viewport settings and restructuring layout' () from BordedDev/snek:bugfix/page-resize-on-mobile into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:27:33 +02:00
f67d7b35f1 Merge pull request 'Potential missing letter fix' () from BordedDev/snek:bugfix/potential-lag-fix into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:25:56 +02:00
BordedDev
9ec62f7471
Enhance mobile responsiveness by updating viewport settings and restructuring layout 2025-05-27 00:23:41 +02:00
6bbbc41360 Update. 2025-05-25 20:50:32 +02:00
d3844ac7a7 Resoted avatars. 2025-05-25 19:01:28 +02:00
9378e95a5b Resoted avatars. 2025-05-25 18:55:39 +02:00
5e4c4ce228 Resoted avatars. 2025-05-25 18:51:55 +02:00
ffc373db62 Channel support. 2025-05-25 12:30:43 +02:00
60266bf0dc Channel support. 2025-05-25 12:30:26 +02:00
BordedDev
8393a80022
Potential missing letter fix 2025-05-25 02:46:49 +02:00
234edf4756 Merge pull request 'Fixed double messaging when live typing' () from BordedDev/snek:bugfix/live-typing into main
Reviewed-on: 
2025-05-25 02:29:04 +02:00
BordedDev
5fd401bfb6
Fixed double messaging when live typing 2025-05-25 02:23:52 +02:00
5663a5f376 Update. 2025-05-25 01:28:42 +02:00
81327a9e20 Update. 2025-05-25 01:25:48 +02:00
2a5b9ad276 Merge pull request 'Fix CSS selector for the last message display' () from BordedDev/snek:bugfix/fix-last-message-time-reply into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 18:46:43 +02:00
BordedDev
662e71c621
Fix CSS selector for the last message display 2025-05-24 18:41:12 +02:00
636adfd997 Update performance. 2025-05-24 15:00:26 +02:00
b94f7a9532 Update. 2025-05-24 14:45:53 +02:00
f954a34384 Update performance. 2025-05-24 14:22:12 +02:00
6a74263606 Update performance. 2025-05-24 14:18:38 +02:00
0bf714061c Update. 2025-05-24 12:58:49 +02:00
e4e2e919c2 Update. 2025-05-24 12:58:49 +02:00
7fe4289f42 Merge pull request 'Made datetime/reply visible when date range is long, also fixes opacity to 1 for the last message's time display' () from BordedDev/snek:bugfix/opacity-for-last-message into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 01:48:07 +02:00
3ce866b7da Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 18:14:32 +02:00
43982c16fa Merge pull request 'Fix timestamp parsing by correcting string slicing for start time' () from BordedDev/snek:bugfix/youtube-timestamp into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-23 18:13:50 +02:00
e33e4196ab Merge branch 'main' into bugfix/youtube-timestamp 2025-05-23 15:45:56 +02:00
2c506db4e4 Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 15:34:30 +02:00
9b9d356849 update. 2025-05-23 15:26:47 +02:00
30b7871583 Update. 2025-05-23 07:07:19 +02:00
1c873b7d02 Update. 2025-05-23 07:02:45 +02:00
539fb262b2 Update. 2025-05-23 07:00:28 +02:00
3bf09f9083 Update. 2025-05-23 06:48:18 +02:00
a55d15b635 The force. 2025-05-23 03:01:20 +02:00
431748c489 Updated dem glow. 2025-05-23 02:39:48 +02:00
a0fb214332 Updated dem glow. 2025-05-23 01:17:41 +02:00
f0545cbf02 Format. 2025-05-23 01:17:41 +02:00
BordedDev
a11c336cf5
Fix timestamp parsing by correcting string slicing for start time 2025-05-22 08:10:39 +02:00
BordedDev
6b083f8b1b
Added feature to show time and reply when messages are from a long time before the next message 2025-05-22 00:19:39 +02:00
BordedDev
89afbba165
Set opacity to 1 for the last message's time display 2025-05-22 00:00:51 +02:00
b2a4887e23 Merge pull request 'Enhance hover effect for time display and avatar visibility' () from BordedDev/snek:feat/show-time-reply-hover into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-21 23:36:25 +02:00
BordedDev
9f577875f2
Enhance hover effect for time display and avatar visibility 2025-05-21 22:30:15 +02:00
c322d6147a Merge pull request 'bugfix/youtube-embed' () from BordedDev/snek:bugfix/youtube-embed into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-20 22:02:16 +02:00
87b6b3362d New avatar. 2025-05-20 04:20:10 +02:00
d261f54327 Merge branch 'main' into bugfix/youtube-embed 2025-05-20 03:34:05 +02:00
59a815f85a Update. 2025-05-19 01:35:34 +02:00
2e837f96c5 Update. 2025-05-19 01:28:22 +02:00
00fce6bd68 Update. 2025-05-19 01:23:12 +02:00
8a85cd7990 Update. 2025-05-19 01:13:18 +02:00
db5431d77d Update. 2025-05-19 01:07:17 +02:00
527b010b24 Added containers. 2025-05-19 01:07:17 +02:00
e1727caa5f Update. 2025-05-18 17:48:22 +02:00
c45b61681d Update. 2025-05-18 16:57:14 +02:00
e09652413f Added nice repo system. 2025-05-18 16:55:02 +02:00
BordedDev
0f337e569f
Fixed gif resizing 2025-05-18 14:51:38 +02:00
59a2668c8c Merge branch 'main' into bugfix/youtube-embed 2025-05-18 03:26:53 +02:00
e79abf4a26 Update stars. 2025-05-17 17:46:59 +02:00
BordedDev
53811ca9b2
Re-added webp fallback 2025-05-17 13:55:46 +02:00
BordedDev
1bed47fbf5
Re-added webp fallback 2025-05-17 13:29:25 +02:00
BordedDev
ffb22165da
Fix YouTube embed parsing and add support for start time; handle missing channel attachments 2025-05-17 13:23:32 +02:00
48c3daf398 Update. 2025-05-17 00:54:15 +02:00
c0b4ba715c t: 2025-05-17 00:53:27 +02:00
00557ec9ea Update. 2025-05-16 01:38:42 +02:00
c387225a6e Update. 2025-05-16 00:41:40 +02:00
93462d4c4b Update. 2025-05-16 00:32:54 +02:00
c5b55399a1 UPdate. 2025-05-16 00:04:19 +02:00
79c39828f0 update. 2025-05-15 23:30:23 +02:00
dd80f3732b Update. 2025-05-15 23:16:28 +02:00
25d109beed Update. 2025-05-15 19:32:40 +02:00
db6d6c0106 Update live type. 2025-05-15 13:18:53 +02:00
af1cf4f5ae Push 2025-05-13 23:33:24 +02:00
0ea0cd96db Update. 2025-05-13 22:54:21 +02:00
3858dcbd62 Update. 2025-05-13 21:25:49 +02:00
b55d74fb12 Update. 2025-05-13 20:35:42 +02:00
a21e3590ef UPdate. 2025-05-13 20:30:48 +02:00
319c1b1b52 UPdate. 2025-05-13 20:28:31 +02:00
964a747f42 UPdate. 2025-05-13 20:27:26 +02:00
12d2870424 UPdate. 2025-05-13 20:25:00 +02:00
015b188d5e UPdate. 2025-05-13 20:24:29 +02:00
8cd2f16c5c UPdate. 2025-05-13 20:20:43 +02:00
d09055986e UPdate. 2025-05-13 20:18:47 +02:00
2e324ff118 Update. 2025-05-13 19:13:50 +02:00
adad5ed4fe Update. 2025-05-13 19:08:18 +02:00
ba3152f553 Update. 2025-05-13 18:33:05 +02:00
a4bea94495 Windows friendly solution. 2025-05-13 18:32:59 +02:00
ac2f68f93f Merge pull request 'Add image conversion and resizing support in channel attachments' () from BordedDev/snek:feat/image-conversion-resizing into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-13 18:19:57 +02:00
BordedDev
f156a153de
Add image conversion and resizing support in channel attachments 2025-05-12 01:47:54 +02:00
c48b84bf3a Update. 2025-05-11 07:52:58 +02:00
01846bf23f Update. 2025-05-11 07:52:22 +02:00
2c90044185 xxx 2025-05-10 21:44:58 +02:00
4d7566de9b Update. 2025-05-10 20:40:56 +02:00
9133b7c3ce Update. 2025-05-10 20:38:32 +02:00
3412aa0bf0 Update. 2025-05-10 15:08:28 +02:00
f0591d4939 UPdate. 2025-05-10 15:03:50 +02:00
dd108c2004 Update. 2025-05-09 17:37:53 +02:00
44ac1d2bfa Update. 2025-05-09 15:55:51 +02:00
1616e4edb9 revert 17c6124a57
revert Minify.
2025-05-09 14:57:22 +02:00
4c34d7eda5 New stuff. 2025-05-09 14:30:53 +02:00
17c6124a57 Minify. 2025-05-09 14:19:29 +02:00
95ad49df43 progress. 2025-05-09 14:08:46 +02:00
7e8ae1632d Update. 2025-05-09 09:56:23 +02:00
e5d155e124 Update. 2025-05-09 09:18:55 +02:00
3ae30f1f76 Do it. 2025-05-09 09:09:33 +02:00
adb59eff68 Do it. 2025-05-09 08:54:33 +02:00
e06776d81d Performance. 2025-05-09 08:24:43 +02:00
a5aac9a337 Patch 2025-05-09 07:55:08 +02:00
ee40c905d4 Update. 2025-05-09 05:38:29 +02:00
c56bf4fb49 Update. 2025-05-09 02:30:43 +02:00
5b28044d9e Update. 2025-05-09 02:17:12 +02:00
e359a8ebe2 Update. 2025-05-09 02:16:40 +02:00
b867b6ba78 Update. 2025-05-09 02:15:57 +02:00
ac570d036c Update. 2025-05-09 02:08:43 +02:00
165dda3210 Update. 2025-05-09 01:36:48 +02:00
02a0253c1d YEah.. 2025-05-09 01:33:59 +02:00
3c0fea6812 ADded tmux. 2025-05-08 02:05:15 +00:00
31062fddbf Update. 2025-05-08 03:32:43 +02:00
d0dd342e27 Update Makefile. 2025-05-08 03:22:32 +02:00
3c1d5d601f Update. 2025-05-08 03:20:06 +02:00
49ec99ef01 Focus while upload. 2025-05-08 01:10:56 +02:00
8799662159 Focus while upload. 2025-05-08 01:09:59 +02:00
f6706c165e Focus while upload. 2025-05-08 01:07:42 +02:00
0a3e151377 Focus while upload. 2025-05-08 01:04:29 +02:00
e153811ff3 Focus while upload. 2025-05-08 01:03:11 +02:00
fa59dbc095 Focus while upload. 2025-05-08 00:59:18 +02:00
d6d2f2892b Focus while upload. 2025-05-08 00:56:55 +02:00
707788583a Focus while upload. 2025-05-08 00:31:21 +02:00
b0666a0090 Merge pull request 'Added file paste/drop support' () from BordedDev/snek:feat/copy-paste-drag-drop into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-06 23:27:25 +02:00
0f6eb5c043 Merge branch 'main' into feat/copy-paste-drag-drop 2025-05-06 23:17:08 +02:00
529ebd23fc Fixed upload. 2025-05-06 23:16:03 +02:00
BordedDev
f7fda2d2c9
Added paste support
Added file drop support
2025-05-06 23:14:52 +02:00
c709ee11c9 Updated security. 2025-05-06 23:02:09 +02:00
6312dfae47 Added windows exception. 2025-05-06 22:17:04 +02:00
061da150f9 Update 2025-05-06 22:00:07 +02:00
46a8b612b4 Added parameters and executable. 2025-05-06 21:44:45 +02:00
1cd0b54656 Update. 2025-04-17 00:05:25 +02:00
4cc70640e4 Upadte. 2025-04-14 23:20:05 +02:00
c36ce17da5 Upadte. 2025-04-14 23:16:52 +02:00
3cfb79c8f5 Upadte. 2025-04-14 23:09:23 +02:00
d4f5a46409 Upadte. 2025-04-14 23:00:05 +02:00
0fa0488385 Upadte. 2025-04-14 22:54:12 +02:00
9fb6e64655 Update. 2025-04-14 22:41:14 +02:00
a3abd854bb Updates. 2025-04-14 22:31:46 +02:00
3b05acffd2 Updates. 2025-04-14 22:31:26 +02:00
bee7d828cd Update. 2025-04-13 23:31:52 +02:00
8ae9aac045 Fixed auth. 2025-04-13 20:28:15 +02:00
e4b0625799 Fixed auth. 2025-04-13 20:26:02 +02:00
4a770848a6 Fixed search space bug. 2025-04-13 19:10:10 +02:00
823892a302 PRoces handler. 2025-04-13 14:47:10 +02:00
9b49e659e5 Update .rcontext.txt 2025-04-13 11:51:32 +02:00
ec9af49f29 Update .rcontext.txt 2025-04-13 11:46:40 +02:00
22668f8a72 Update vibe coding. 2025-04-13 11:39:12 +02:00
a1840cd034 Sats. 2025-04-13 05:08:20 +02:00
bc65752ea2 Cache stats. 2025-04-13 05:06:53 +02:00
3594ac1f59 Performance upgrade. 2025-04-10 13:34:32 +02:00
0e6fbd523c update. 2025-04-10 08:37:05 +02:00
743593affe Formatting. 2025-04-09 15:21:23 +02:00
44dd77cec5 Shed. 2025-04-09 15:12:34 +02:00
8fa216c06c New video embedding 2025-04-09 11:09:00 +02:00
c529fc87fd New video embedding 2025-04-09 11:07:09 +02:00
656ea5f90e New video embedding 2025-04-09 11:03:39 +02:00
2582df360a New video embedding 2025-04-09 11:02:45 +02:00
6673f7b615 New video embedding 2025-04-09 10:59:09 +02:00
94e94cf7ca New video embedding 2025-04-09 10:56:52 +02:00
e6bd7aa152 New video embedding 2025-04-09 10:55:30 +02:00
087f9c10b4 New video embedding 2025-04-09 10:52:39 +02:00
6138cad782 New video embedding 2025-04-09 10:46:53 +02:00
c6575d8e52 New video embedding 2025-04-09 10:43:46 +02:00
b0a97ad267 New video embedding 2025-04-09 10:35:15 +02:00
b31c286a8b New video embedding 2025-04-09 10:34:57 +02:00
13f1d2f390 Performance upgrade, lock fix. 2025-04-08 21:32:18 +02:00
d23ed3711a Update. 2025-04-08 20:31:15 +02:00
d2e2bb8117 Update. 2025-04-08 05:01:27 +02:00
d71d5da6bc Updates. 2025-04-08 04:20:35 +02:00
75593fd6bb Merge pull request 'Potential fix for manifest, the icons were being marked as instability since they were the wrong size which might fix firefox android' () from BordedDev/snek:bugfix/webmanifest-instability into main
Reviewed-on: 
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-04-07 11:24:13 +00:00
162 changed files with 11303 additions and 1503 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
snek-container-compose.yml
.r_history
.vscode
.history

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

View File

@ -1,11 +1,12 @@
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 -y
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 cp r /usr/local/bin
RUN mv r /usr/local/bin/r
CMD ["r"]

View File

@ -5,21 +5,27 @@ GUNICORN=./.venv/bin/gunicorn
GUNICORN_WORKERS = 1
PORT = 8081
python:
$(PYTHON)
shell:
.venv/bin/snek shell
dump:
@$(PYTHON) -m snek.dump
build:
serve: run
run:
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
.venv/bin/snek serve
install:
install: ubuntu
python3.12 -m venv .venv
$(PIP) install -e .
ubuntu:
docker build -f DockerfileUbuntu -t snek_ubuntu .

21
cert.pem Normal file
View File

@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUB7PQvHZD6v8hfxeaDbU3hC0nGQQwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MDYxOTUzMDhaFw0yNjA0
MDYxOTUzMDhaMEUxCzAJBgNVBAYTAk5MMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCtYf8PP7QjRJOfK6zmfAZhSKwMowCSYijKeChxsgyn
hDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXesBBPhie+4KmtsykiI7QEHXVVrWHba
6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71Fme4ofJ2Plb7PnF53R4Tc3aTMdIW
HrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrldPHYbTGvBcDUil7qZ8hZ8ZxLMzu3
GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSyVz+fVgvLozNL9kV89hbZo7H/M37O
zmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZyXgXgdMdLAgMBAAGjUzBRMB0GA1Ud
DgQWBBQtGeiVTYjzWb2hTqJwipRVXU1LnzAfBgNVHSMEGDAWgBQtGeiVTYjzWb2h
TqJwipRVXU1LnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAc
1BacrGMlCd5nfYuvQfv0DdTVGc2FSqxPMRGrZKfjvjemgPMs0+DqUwCJiR6oEOGb
atOYoIBX9KGXSUKRYYc/N75bslwfV1CclNqd2mPxULfks/D8cAzf2mgw4kYSaDHs
tJkywBe9L6eIK4cQ5YJvutVNVKMYPi+9w+wKog/FafkamFfX/3SLCkGmV0Vv4g0q
Ro9KmTTQpJUvd63X8bONLs1t8p+HQfWmKlhuVn5+mncNdGREe8dbciXE5FKu8luN
dr/twoTZTPhmIHPmVEeNxS8hFSiu0iUPTO0HcCAODILGbtXbClA+1Z0ukiRfUya6
tgVuEk0c64L86qGP7Ply
-----END CERTIFICATE-----

28
key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf
K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes
BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71
Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl
dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy
Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy
XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1
NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER
TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q
RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC
1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG
eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F
aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C
+DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz
OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl
v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB
orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs
eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s
qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F
8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC
L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC
DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E
9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv
9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45
5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3
xhhX7RsDOYMFKmn59DS1CQCZAA==
-----END PRIVATE KEY-----

View File

@ -16,9 +16,10 @@ requires-python = ">=3.12"
dependencies = [
"mkdocs>=1.4.0",
"lxml",
"IPython",
"shed",
"app @ git+https://retoor.molodetz.nl/retoor/app",
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
"beautifulsoup4",
"gunicorn",
"imgkit",
@ -31,6 +32,21 @@ dependencies = [
"emoji",
"aiofiles",
"PyJWT",
"multiavatar"
"multiavatar",
"gitpython",
'uvloop; platform_system != "Windows"',
"humanize",
"Pillow",
"pillow-heif",
"IP2Location",
"bleach"
]
[tool.setuptools.packages.find]
where = ["src"] # <-- this changed
[tool.setuptools.package-data]
"*" = ["*.*"]
[project.scripts]
snek = "snek.__main__:main"

Binary file not shown.

View File

@ -1 +1,68 @@
"""
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
# Split into key and value at the first '='
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

View File

@ -1,6 +1,129 @@
import pathlib
import shutil
import sqlite3
import asyncio
import click
from aiohttp import web
from IPython import start_ipython
from snek.shell import Shell
from snek.app import Application
@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",
)
def serve(port, host, db_path):
# init(db_path)
# asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
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():
cli()
if __name__ == "__main__":
web.run_app(Application(), port=8081, host="0.0.0.0")
main()

View File

@ -1,15 +1,18 @@
import asyncio
import logging
import pathlib
import time
import ssl
import uuid
from datetime import datetime
from snek import snode
from snek.view.threads import ThreadsView
logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address
import IP2Location
from aiohttp import web
from aiohttp_session import (
get_session as session_get,
@ -18,35 +21,61 @@ from aiohttp_session import (
)
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
from snek.docs.app import Application as DocsApplication
from snek.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
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
from snek.system.template import (
EmojiExtension,
LinkifyExtension,
PythonExtension,
sanitize_html,
)
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView, ChannelView
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveView
from snek.view.drive import DriveApiView, DriveView
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.container import ContainerView
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.stats import StatsView
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
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware
@ -56,6 +85,33 @@ async def session_middleware(request, handler):
return response
@web.middleware
async def ip2location_middleware(request, handler):
response = await handler(request)
return response
ip = request.headers.get("X-Forwarded-For", request.remote)
ipaddress = ip_address(ip)
if ipaddress.is_private:
return response
if not request.app.session.get("uid"):
return response
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
if not user:
return response
location = request.app.ip2location.get(ip)
user["city"]
if user["city"] != location.city:
user["country_long"] = location.country
user["country_short"] = locaion.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("/"):
@ -65,15 +121,20 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication):
def __init__(self, *args, **kwargs):
middlewares = [
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware,
]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5 * args,
**kwargs,
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
self.tasks = asyncio.Queue()
@ -83,14 +144,73 @@ class Application(BaseApplication):
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.setup_router()
self.ssh_server = None
self.sync_service = None
self.executor = None
self.cache = Cache(self)
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
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)
@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.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
async def create_task(self, task):
await self.tasks.put(task)
@ -99,10 +219,7 @@ class Application(BaseApplication):
task = await self.tasks.get()
self.db.begin()
try:
task_start = time.time()
await task
task_end = time.time()
print(f"Task {task} took {task_end - task_start} seconds")
self.tasks.task_done()
except Exception as ex:
print(ex)
@ -134,6 +251,7 @@ class Application(BaseApplication):
show_index=True,
)
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)
@ -144,11 +262,13 @@ class Application(BaseApplication):
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/{rel_path:.*}", DriveView)
self.router.add_view("/drive.bin", UploadView)
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView)
@ -157,24 +277,57 @@ class Application(BaseApplication):
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.bin", ChannelAttachmentView
)
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", DriveView)
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView)
self.webdav = WebdavApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp(
"/docs",
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
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/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.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await self.render_template(
"test.html", request, context={"name": "retoor"}
return await whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
)
async def handle_http_get(self, request: web.Request):
@ -194,17 +347,18 @@ class Application(BaseApplication):
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
)
parent_object = await subscribed_channel.get_channel()
last_message = await parent_object.get_last_message()
color = None
if last_message:
@ -221,7 +375,6 @@ class Application(BaseApplication):
item["uid"] = subscribed_channel["channel_uid"]
item["new_count"] = subscribed_channel["new_count"]
print(item)
channels.append(item)
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
@ -229,22 +382,73 @@ class Application(BaseApplication):
context["channels"] = channels
if "user" not in context:
context["user"] = await self.services.user.get(
uid=request.session.get("uid")
request.session.get("uid")
)
return await super().render_template(template, request, context)
self.template_path.joinpath(template)
await self.services.user.get_template_path(request.session.get("uid"))
executor = ThreadPoolExecutor(max_workers=200)
self.original_loader = self.jinja2_env.loader
self.jinja2_env.loader = await self.get_user_template_loader(
request.session.get("uid")
)
rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader
# rendered.text = whitelist_attributes(rendered.text)
# rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered
async def static_handler(self, request):
file_name = request.match_info.get("filename", "")
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)
loop = asyncio.get_event_loop()
loop.set_default_executor(executor)
app = Application(db_path="sqlite:///snek.db")
async def main():
await web._run_app(app, port=8081, host="0.0.0.0")
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain("cert.pem", "key.pem")
await web._run_app(app, port=8081, host="0.0.0.0", ssl_context=ssl_context)
if __name__ == "__main__":

129
src/snek/balancer.py Normal file
View File

@ -0,0 +1,129 @@
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)
self.lock = asyncio.Lock()
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):
async with self.lock:
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()
async with self.lock:
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...")

View File

@ -1,14 +1,25 @@
from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement
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)
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)
profile = FormInputElement(
name="profile",
place_holder="Tell about yourself.",
required=False,
max_length=300,
)
action = FormButtonElement(
name="action", value="submit", text="Save", type="button"
)
)

View File

@ -1,11 +1,15 @@
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.system.object import Object
@ -23,6 +27,10 @@ def get_mappers(app=None):
"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),
}
)

View File

@ -0,0 +1,7 @@
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper
class ChannelAttachmentMapper(BaseMapper):
table_name = "channel_attachment"
model_class = ChannelAttachmentModel

View File

@ -0,0 +1,7 @@
from snek.model.container import Container
from snek.system.mapper import BaseMapper
class ContainerMapper(BaseMapper):
model_class = Container
table_name = "container"

7
src/snek/mapper/push.py Normal file
View File

@ -0,0 +1,7 @@
from snek.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper
class PushMapper(BaseMapper):
model_class = PushRegistrationModel
table_name = "push_registration"

View File

@ -0,0 +1,7 @@
from snek.model.repository import RepositoryModel
from snek.system.mapper import BaseMapper
class RepositoryMapper(BaseMapper):
model_class = RepositoryModel
table_name = "repository"

View File

@ -5,3 +5,16 @@ from snek.system.mapper import BaseMapper
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:
print(ex)
return []

View File

@ -1,13 +1,17 @@
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.system.object import Object
@ -25,6 +29,10 @@ def get_models():
"drive": DriveModel,
"notification": NotificationModel,
"user_property": UserPropertyModel,
"repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel,
"container": Container,
"push_registration": PushRegistrationModel,
}
)

View File

@ -0,0 +1,15 @@
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"
)

View File

@ -1,3 +1,5 @@
from datetime import datetime, timezone
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
@ -7,6 +9,14 @@ 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"])

View File

@ -0,0 +1,11 @@
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)

View File

@ -9,6 +9,9 @@ class DriveItemModel(BaseModel):
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):

View File

@ -0,0 +1,8 @@
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)

View File

@ -0,0 +1,10 @@
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)
is_private = ModelField(name="is_private", required=False, kind=bool)

View File

@ -29,6 +29,38 @@ class UserModel(BaseModel):
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

View File

@ -1,5 +1,3 @@
import mimetypes
from snek.system.model import BaseModel, ModelField
@ -7,4 +5,3 @@ class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
value = ModelField(name="path", required=True, kind=str)

View File

@ -0,0 +1,242 @@
import json
import asyncio
import aiohttp
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
class DatasetMethod:
def __init__(self, dt, name):
self.dt = dt
self.name = name
def __call__(self, *args, **kwargs):
return self.dt.ds.call(
self.dt.name,
self.name,
*args,
**kwargs
)
class DatasetTable:
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
class WebSocketClient2:
def __init__(self, uri):
self.uri = uri
self.loop = asyncio.get_event_loop()
self.websocket = None
self.receive_queue = asyncio.Queue()
# Schedule connection setup
if self.loop.is_running():
# Schedule connect in the existing loop
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
else:
# If loop isn't running, connect synchronously
self.loop.run_until_complete(self._connect())
async def _connect(self):
self.websocket = await websockets.connect(self.uri)
# Start listening for messages
asyncio.create_task(self._receive_loop())
async def _receive_loop(self):
try:
async for message in self.websocket:
await self.receive_queue.put(message)
except Exception:
pass # Handle exceptions as needed
def send(self, message: str):
if self.loop.is_running():
# Schedule send in the existing loop
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
else:
# If loop isn't running, run directly
self.loop.run_until_complete(self.websocket.send(message))
def receive(self):
# Wait for a message synchronously
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
return future.result()
def close(self):
if self.websocket:
if self.loop.is_running():
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
else:
self.loop.run_until_complete(self.websocket.close())
import websockets
class DatasetWrapper(object):
def __init__(self):
self.ws = WebSocketClient()
def begin(self):
self.call(None, 'begin')
def commit(self):
self.call(None, 'commit')
def __getitem__(self, name):
return DatasetTable(self, name)
def query(self, *args, **kwargs):
return self.call(None, 'query', *args, **kwargs)
def call(self, table, method, *args, **kwargs):
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
#if method in ['find','find_one']:
payload["call_uid"] = str(uuid.uuid4())
self.ws.write(json.dumps(payload))
if payload["call_uid"]:
response = self.ws.read()
return json.loads(response)['result']
return True
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
app = web.Application()
view = DatasetWebSocketView()
app.router.add_get('/db', view.handle)
async def run_server():
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', 3131)
await site.start()
print("Server started at http://localhost:8080")
await asyncio.Event().wait()
async def client():
print("x")
d = DatasetWrapper()
print("y")
for x in range(100):
for x in range(100):
if d['test'].insert({"name": "test", "number":x}):
print(".",end="",flush=True)
print("")
print(d['test'].find_one(name="test", order_by="-number"))
print("DONE")
import time
async def main():
await run_server()
import sys
if __name__ == '__main__':
if sys.argv[1] == 'server':
asyncio.run(main())
if sys.argv[1] == 'client':
asyncio.run(client())

View File

@ -0,0 +1,54 @@
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)

103
src/snek/schema.sql Normal file
View File

@ -0,0 +1,103 @@
CREATE TABLE IF NOT EXISTS http_access (
id INTEGER NOT NULL,
created TEXT,
path TEXT,
duration FLOAT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL,
color TEXT,
created_at TEXT,
deleted_at TEXT,
email TEXT,
is_admin TEXT,
last_ping TEXT,
nick TEXT,
password TEXT,
uid TEXT,
updated_at TEXT,
username TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS channel (
id INTEGER NOT NULL,
created_at TEXT,
created_by_uid TEXT,
deleted_at TEXT,
description 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 IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
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 IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE IF NOT EXISTS broadcast (
id INTEGER NOT NULL,
channel_uid TEXT,
message TEXT,
created_at TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
html TEXT,
message TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification (
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 IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
is_private BIGINT,
name TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);

View File

@ -1,17 +1,23 @@
import functools
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.system.object import Object
from snek.service.statistics import StatisticsService
@functools.cache
def get_services(app):
@ -27,6 +33,13 @@ def get_services(app):
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app),
"user_property": UserPropertyService(app=app),
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
"push": PushService(app=app),
"statistics": StatisticsService(app=app),
}
)

View File

@ -1,3 +1,4 @@
import pathlib
from datetime import datetime
from snek.system.model import now
@ -7,6 +8,21 @@ 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:
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
@ -47,6 +63,7 @@ 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}.")
@ -58,6 +75,13 @@ class ChannelService(BaseService):
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,
@ -77,7 +101,7 @@ class ChannelService(BaseService):
if (
datetime.fromisoformat(now())
- datetime.fromisoformat(user["last_ping"])
).total_seconds() < 20:
).total_seconds() < 180:
yield user
async def get_for_user(self, user_uid):

View File

@ -0,0 +1,25 @@
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}.")

View File

@ -10,6 +10,13 @@ class ChannelMemberService(BaseService):
channel_member["new_count"] = 0
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",
{"channel_uid": channel_uid},
):
yield model["user_uid"]
async def create(
self,
channel_uid,

View File

@ -1,15 +1,34 @@
from snek.system.service import BaseService
from snek.system.template import whitelist_attributes
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, message):
async def maintenance(self):
async for message in self.find():
updated_at = message["updated_at"]
html = message["html"]
await self.save(message)
self.mapper.db['channel_message'].upsert(
{
"uid": message["uid"],
"updated_at": updated_at,
},
["uid"],
)
if html != message["html"]:
print("Reredefined message", message["uid"])
async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model["is_final"] = is_final
context = {}
@ -27,10 +46,11 @@ class ChannelMessageService(BaseService):
try:
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
except Exception as ex:
print(ex, flush=True)
if await self.save(model):
if await super().save(model):
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
@ -50,6 +70,23 @@ class ChannelMessageService(BaseService):
"username": user["username"],
}
async def save(self, model):
context = {}
context.update(model.record)
user = await self.app.services.user.get(model["user_uid"])
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
results = []
offset = page * page_size

View File

@ -4,12 +4,40 @@ from snek.system.service import BaseService
class ChatService(BaseService):
async def send(self, user_uid, channel_uid, message):
async def finalize(self, message_uid):
channel_message = await self.services.channel_message.get(uid=message_uid)
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
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)
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"],
},
)
await self.app.create_task(
self.services.notification.create_channel_message(message_uid)
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"]
@ -30,10 +58,11 @@ class ChatService(BaseService):
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": is_final,
},
)
await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid)
)
return True
return channel_message

View File

@ -0,0 +1,109 @@
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 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 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))
+ ":"
+ "/home/ubuntu"
],
)
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}")

66
src/snek/service/db.py Normal file
View File

@ -0,0 +1,66 @@
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)

View File

@ -16,4 +16,5 @@ class DriveItemService(BaseService):
if await self.save(model):
return model
errors = await model.errors
print("XXXXXXXXXX")
raise Exception(f"Failed to create drive item: {errors}.")

View File

@ -1,3 +1,4 @@
from snek.system.markdown import strip_markdown
from snek.system.model import now
from snek.system.service import BaseService
@ -33,6 +34,8 @@ 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(
@ -62,4 +65,20 @@ class NotificationService(BaseService):
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:
print(f"Failed to send push notification:", e)
self.app.db.commit()

267
src/snek/service/push.py Normal file
View File

@ -0,0 +1,267 @@
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}"
)

View File

@ -0,0 +1,53 @@
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)
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):
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
return await self.save(model)

View File

@ -1,6 +1,13 @@
import asyncio
import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
from snek.system.model import now
class SocketService(BaseService):
@ -15,10 +22,9 @@ class SocketService(BaseService):
return False
try:
await self.ws.send_json(data)
except Exception as ex:
print(ex, flush=True)
except Exception:
self.is_connected = False
return True
return self.is_connected
async def close(self):
if not self.is_connected:
@ -34,16 +40,36 @@ class SocketService(BaseService):
self.sockets = set()
self.users = {}
self.subscriptions = {}
self.last_update = str(datetime.now())
async def user_availability_service(self):
logger.info("User availability update service started.")
while True:
logger.info("Updating user availability...")
users_updated = []
for s in self.sockets:
if not s.is_connected:
continue
if s.user not in users_updated:
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
users_updated.append(s.user)
logger.info(
f"Updated user availability for {len(users_updated)} online users."
)
await asyncio.sleep(60)
async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
logger.info(f"Added socket for user {s.user['username']}")
if not self.users.get(user_uid):
self.users[user_uid] = set()
self.users[user_uid].add(s)
async def subscribe(self, ws, channel_uid, user_uid):
return
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
@ -57,13 +83,22 @@ class SocketService(BaseService):
return count
async def broadcast(self, channel_uid, message):
async for channel_member in self.app.services.channel_member.find(
channel_uid=channel_uid
):
await self.send_to_user(channel_member["user_uid"], message)
await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
sent = 0
try:
async for user_uid in self.services.channel_member.get_user_uids(
channel_uid
):
sent += await self.send_to_user(user_uid, message)
except Exception as ex:
print(ex, flush=True)
logger.info(f"Broadcasted a message to {sent} users.")
return True
async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close()
logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s)

View File

@ -0,0 +1,91 @@
from snek.system.service import BaseService
import sqlite3
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()

View File

@ -7,10 +7,13 @@ 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()
if not query:
raise []
return []
results = []
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
@ -29,16 +32,55 @@ class UserService(BaseService):
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):
print(username, password, flush=True)
success = await self.validate_login(username, password)
print(success, flush=True)
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:
pass
return folder
async def get_home_folder(self, user_uid):
folder = pathlib.Path(f"./drive/{user_uid}")
if not folder.exists():

View File

@ -0,0 +1,35 @@
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"],
)
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

550
src/snek/sgit.py Normal file
View File

@ -0,0 +1,550 @@
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("/create/{repo_name}", self.create_repository),
web.delete("/delete/{repo_name}", self.delete_repository),
web.get("/clone/{repo_name}", self.clone_repository),
web.post("/push/{repo_name}", self.push_repository),
web.post("/pull/{repo_name}", self.pull_repository),
web.get("/status/{repo_name}", self.status_repository),
# web.get('/list', self.list_repositories),
web.get("/branches/{repo_name}", self.list_branches),
web.post("/branches/{repo_name}", self.create_branch),
web.get("/log/{repo_name}", self.commit_log),
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
web.get("/{path:.+}/info/refs", self.git_smart_http),
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
request["user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
if not request["user"]:
return None, None
request["repository_path"] = (
await self.parent.services.user.get_repository_path(request["user"]["uid"])
)
return request["user"]["username"], request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path:
return web.Response(
status=401,
headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
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):
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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 {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):
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
#'''
try:
shutil.rmtree(self.repo_path(repository_path, repo_name))
logger.info(f"Deleted repository: {repo_name} for user {username}")
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):
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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):
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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 {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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 {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):
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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):
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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):
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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 {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):
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
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):
request["username"]
repo_name = request.match_info["repo_name"]
file_path = request.match_info.get("file_path", "")
branch = request.query.get("branch", "main")
repository_path = request["repository_path"]
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):
request["username"]
repository_path = request["repository_path"]
path = request.path
async def get_repository_path():
req_path = path.lstrip("/")
if req_path.endswith("/info/refs"):
repo_name = req_path[: -len("/info/refs")]
elif req_path.endswith("/git-upload-pack"):
repo_name = req_path[: -len("/git-upload-pack")]
elif req_path.endswith("/git-receive-pack"):
repo_name = req_path[: -len("/git-receive-pack")]
else:
repo_name = req_path
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(f"Resolved repo path: {repo_dir}")
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)

19
src/snek/shell.py Normal file
View File

@ -0,0 +1,19 @@
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)

125
src/snek/snode.py Normal file
View File

@ -0,0 +1,125 @@
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)

81
src/snek/sssh.py Normal file
View File

@ -0,0 +1,81 @@
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

View File

@ -1,236 +1,276 @@
// Written by retoor@molodetz.nl
// This project implements a client-server communication system using WebSockets and REST APIs.
// This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints.
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
import { Schedule } from './schedule.js';
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
export class RESTClient {
debug = false;
debug = false;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += '?' + encodedParams;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
return result;
}
async post(url, data) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
async post(url, data) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
}
}
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 = {};
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);
}
connect() {
if (this._waitConnect) {
return this._waitConnect;
}
return new Promise((resolve) => {
this._waitConnect = resolve;
console.debug("Connecting..");
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
generateUniqueId() {
return "id-" + Math.random().toString(36).substr(2, 9);
}
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
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);
}
});
}
generateUniqueId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
_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");
};
}
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,
});
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
}
export class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
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",
}
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);
});
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);
});
}
});
}
}
export class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) {
this.typeEventChannel_uid = 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
if (this.is_pinging) return false;
this.is_pinging = true;
await this.rpc.ping(...args);
this.is_pinging = false;
}
ntsh(times,message) {
if(!message)
message = "Nothing to see here!"
if(!times)
times=100
for(let x = 0; x < times; x++){
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
}
}
async forcePing(...arg) {
await this.rpc.ping(...args);
}
starField = null
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
}, 15000);
this.typeEventChannelUid = null;
this.typeListener = setInterval(() => {
if (this.typeEventChannelUid) {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
});
async forcePing(...arg) {
await this.rpc.ping(...args);
}
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.is_pinging = false
this.ping_interval = setInterval(() => {
this.ping("active")
}, 15000)
const me = this
this.ws.addEventListener("connected", (data) => {
this.ping("online")
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.rpc.getUser(null).then(user => {
me.user = user;
});
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
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)
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
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}`);
});
}));
}
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? "days" : "day"} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
}
return "just now";
}
async benchMark(times = 100, message = "Benchmark Message") {
const promises = [];
const me = this;
for (let i = 0; i < times; i++) {
promises.push(
this.rpc.getChannels().then((channels) => {
channels.forEach((channel) => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}),
);
}
}
}
export const app = new App();
window.app = app;
window.app = app;

View File

@ -5,6 +5,14 @@
box-sizing: border-box;
}
html {
height: 100%;
}
.hidden {
display: none;
}
.gallery {
padding: 50px;
height: auto;
@ -25,33 +33,35 @@ body {
background-color: #000000;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%;
height: 100%;
}
main {
display: flex;
flex: 1;
overflow: hidden;
grid-area: chat-area;
}
header {
background-color: #000000;
padding-top: 10px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 10px;
grid-area: header;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
header .logo {
color: #fff;
color: #fff;
font-weight: bold;
font-size: 1.2em;
font-size: 1.2em;
color: #fff;
}
@ -81,13 +91,13 @@ a {
}
h1 {
font-size: 2em;
color: #f05a28;
font-size: 2em;
color: #f05a28;
}
h2 {
font-size: 1.4em;
color: #f05a28;
font-size: 1.4em;
color: #f05a28;
}
@ -95,13 +105,11 @@ h2 {
flex: 1;
display: flex;
flex-direction: column;
background-color: #000000;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
background-color: #000000;
user-select: none;
}
@ -112,10 +120,10 @@ h2 {
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.message-list {
@ -132,7 +140,11 @@ footer {
-ms-overflow-style: none;
padding: 10px;
height: 10px;
background: #000000;
}
.chat-messages {
display: flex;
flex-direction: column;
}
.container {
@ -152,6 +164,10 @@ footer {
}
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
@ -209,6 +225,55 @@ footer {
hyphens: auto;
}
.message-content .spoiler {
background-color: rgba(255, 255, 255, 0.1);
/*color: transparent;*/
cursor: pointer;
border-radius: 0.5rem;
padding: 0.5rem;
position: relative;
height: 2.5rem;
overflow: hidden;
max-width: unset;
}
.message-content .spoiler * {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active {
/*color: #e6e6e6;*/
/*transition: color 0.3s ease-in;*/
height: unset;
overflow: unset;
}
@keyframes delay-pointer-events {
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
.spoiler:hover * {
animation: unset;
}
.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * {
opacity: 1;
transition: opacity 0.3s ease-in;
pointer-events: auto;
visibility: visible;
animation: delay-pointer-events 0.2s linear;
}
.message-content {
max-width: 100%;
}
@ -228,15 +293,14 @@ footer {
.chat-input {
padding: 15px;
background-color: #000000;
display: flex;
align-items: center;
}
input[type="text"], .chat-input textarea {
flex: 1;
background-color: #000000;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
@ -273,13 +337,18 @@ input[type="text"], .chat-input textarea {
}
.avatar {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.author, .time {
.author {
display: none;
}
&:not(:hover, :focus-within, :active) .time {
opacity: 0;
}
}
.message.switch-user {
@ -291,6 +360,7 @@ input[type="text"], .chat-input textarea {
.avatar {
user-select: none;
opacity: 1;
max-height: unset;
}
.author {
@ -298,9 +368,10 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:last-child {
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.time {
display: block;
opacity: 1;
}
}
@ -334,11 +405,11 @@ a {
.sidebar {
width: 250px;
background-color: #000000;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
@ -366,39 +437,232 @@ a {
color: #fff;
}
@media only screen and (max-width: 768px) {
header{
position:fixed;
top: 0;
left: 0;
text-overflow: ellipsis;
width:100%;
*{
font-size: 12px !important;
}
.logo {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 12px;
}
}
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
@keyframes glow {
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
}
.glow {
animation: glow 1s;
}
@media only screen and (max-width: 768px) {
header {
top: 0;
left: 0;
text-overflow: ellipsis;
width: 100%;
display: flex;
flex-direction: column;
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
}
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 12px;
padding: 24px;
background-color: #000; /* Deep black */
color: #f1f1f1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
width: 90%;
max-width: 400px;
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
z-index: 1000;
}
/* Backdrop styling */
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Title and content */
dialog .dialog-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 16px;
color: #fff;
}
dialog .dialog-content {
font-size: 1rem;
color: #ccc;
margin-bottom: 20px;
}
/* Button layout */
dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
dialog .dialog-button {
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background 0.2s ease;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
background-color: #f05a28;
color: white;
}
dialog .dialog-button.primary:hover {
background-color: #f05a28;
}
dialog .dialog-button.secondary {
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #f0b84c;
}
dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] {
/* slightly darker + lower saturation of the live colour */
background-color: #70321e; /* muted burnt orange */
color: #bfbfbf; /* light grey text */
opacity: .55; /* unified fade */
cursor: not-allowed;
pointer-events: none;
}
/* ---------- SECONDARY (yellow) ---------- */
dialog .dialog-button.secondary:disabled,
dialog .dialog-button.secondary[aria-disabled="true"] {
background-color: #6c5619; /* muted mustard */
color: #bfbfbf;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
dialog .dialog-button:disabled:focus {
outline: none;
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
th {
min-width: 100px;
}

View File

@ -1,69 +1,345 @@
// Written by retoor@molodetz.nl
import { app } from "../app.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.
class ChatInputComponent extends HTMLElement {
autoCompletions = {
"example 1": () => {
},
"example 2": () => {
},
}
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
expiryTimer = null;
queuedMessage = null;
lastMessagePromise = null;
// Includes standard DOM manipulation methods; no external imports used.
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
class ChatInputElement extends HTMLElement {
_chatWindow = null
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || "";
}
set chatWindow(value){
this._chatWindow = value
get value() {
return this._value;
}
get chatWindow(){
return this._chatWindow
}
get channelUid() {
return this.chatWindow.channel.uid
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.container = document.createElement('div');
this.container.classList.add('chat-input');
this.container.innerHTML = `
<textarea placeholder="Type a message..." rows="2"></textarea>
<upload-button></upload-button>
`;
this.textBox = this.container.querySelector('textarea');
this.uploadButton = this.container.querySelector('upload-button');
this.uploadButton.chatInput = this
this.textBox.addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));
const message = e.target.value;
const button = this.container.querySelector('button');
button.disabled = !message;
set value(value) {
this._value = value;
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions)
}
resolveAutoComplete(input) {
let value = null;
for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(input.split(" ", 1)[0])) {
if (value) {
return null;
}
value = key;
}
}
return value;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick])
}
extractMentions(text) {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
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;
}
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
this.textBox.addEventListener('change', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
console.error(e.target.value);
return updatedText;
}
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.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
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.textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput("");
});
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid)
return;
}
this.updateFromInput(e.target.value);
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
const message = e.target.value.trim();
if (!message) return;
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
e.target.value = '';
autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
}
if (e.repeat) {
this.updateFromInput(e.target.value);
}
});
this.component.appendChild(this.container);
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;
}
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user.color).catch(() => {
});
}
}
finalizeMessage(messageUid) {
if (!messageUid) {
if (this.value.trim() === "") {
return;
}
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
} else if (messageUid.startsWith("?")) {
const lastQueuedMessage = this.queuedMessage;
this.lastMessagePromise?.then((uid) => {
const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve();
return updatePromise.finally(() => {
return app.rpc.finalizeMessage(uid);
})
})
} else {
app.rpc.finalizeMessage(messageUid)
}
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
this.lastMessagePromise = null
}
updateFromInput(value) {
if (this.expiryTimer) {
clearTimeout(this.expiryTimer);
this.expiryTimer = null;
}
this.value = value;
this.flagTyping()
if (this.liveType && value[0] !== "/") {
this.expiryTimer = setTimeout(() => {
this.finalizeMessage(this.messageUid)
}, this.liveTypeInterval * 1000);
const messageText = this.replaceMentionsWithAuthors(value);
if (this.messageUid?.startsWith("?")) {
this.queuedMessage = messageText;
} else if (this.messageUid) {
app.rpc.updateMessageText(this.messageUid, messageText).then((d) => {
if (!d.success) {
this.messageUid = null
this.updateFromInput(value)
}
})
} else {
const placeHolderId = "?" + crypto.randomUUID();
this.messageUid = placeHolderId;
this.lastMessagePromise = this.sendMessage(this.channelUid, messageText, !this.liveType).then(async (uid) => {
if (this.liveType && this.messageUid === placeHolderId) {
if (this.queuedMessage && this.queuedMessage !== messageText) {
await app.rpc.updateMessageText(uid, this.queuedMessage)
}
this.messageUid = uid;
}
return uid
});
}
}
}
async sendMessage(channelUid, value, is_final) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value, is_final);
}
}
customElements.define('chat-input', ChatInputElement);
customElements.define("chat-input", ChatInputComponent);

View File

@ -6,77 +6,77 @@
// The MIT License (MIT)
// 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.
class ChatWindowElement extends HTMLElement {
receivedHistory = false;
channel = null
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('section');
this.app = app;
this.shadowRoot.appendChild(this.component);
}
receivedHistory = false;
channel = null;
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("section");
this.app = app;
this.shadowRoot.appendChild(this.component);
}
get user() {
return this.app.user;
}
get user() {
return this.app.user;
}
async connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.component.classList.add("chat-area");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
async connectedCallback() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/base.css";
this.component.appendChild(link);
this.component.classList.add("chat-area");
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
const chatTitle = document.createElement('h2');
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
const channels = await app.rpc.getChannels();
const channel = channels[0];
this.channel = channel;
chatTitle.innerText = channel.name;
const chatTitle = document.createElement("h2");
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const channelElement = document.createElement('message-list');
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
const channels = await app.rpc.getChannels();
const channel = channels[0];
this.channel = channel;
chatTitle.innerText = channel.name;
const chatInput = document.createElement('chat-input');
chatInput.chatWindow = this;
chatInput.addEventListener("submit", (e) => {
app.rpc.sendMessage(channel.uid, e.detail);
});
this.container.appendChild(chatInput);
const channelElement = document.createElement("message-list");
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
this.component.appendChild(this.container);
const chatInput = document.createElement("chat-input");
chatInput.chatWindow = this;
chatInput.addEventListener("submit", (e) => {
app.rpc.sendMessage(channel.uid, e.detail);
});
this.container.appendChild(chatInput);
const messages = await app.rpc.getMessages(channel.uid);
messages.forEach(message => {
if (!message['user_nick']) return;
channelElement.addMessage(message);
});
this.component.appendChild(this.container);
const me = this;
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({"block": "end"});
});
}
const messages = await app.rpc.getMessages(channel.uid);
messages.forEach((message) => {
if (!message["user_nick"]) return;
channelElement.addMessage(message);
});
const me = this;
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({ block: "end" });
});
}
}
customElements.define('chat-window', ChatWindowElement);
customElements.define("chat-window", ChatWindowElement);

View File

@ -0,0 +1,102 @@
import { app } from "./app.js";
import { EventHandler } from "./event-handler.js";
export class Container extends EventHandler{
status = "unknown"
cpus = 0
memory = "0m"
image = "unknown:unknown"
name = null
channelUid = null
log = false
bytesSent = 0
bytesReceived = 0
_container = null
render(el){
if(this._container == null){
this._container = el
this.terminal.open(this._container)
this.terminal.onData(data => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
});
}
this.refresh()
this.terminal.focus()
}
refresh(){
//this._fitAddon.fit();
this.ws.send("\x0C");
}
toggle(){
this._container.classList.toggle("hidden")
this.refresh()
}
constructor(channelUid,log){
super()
this.terminal = new Terminal({ cursorBlink: true ,theme: {
background: 'rgba(0, 0, 0, 0)', // Fully transparent
}
});
this._fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this._fitAddon);
window.addEventListener("resize", () => this._fitAddon.fit());
this.log = log ? true : false
this.channelUid = channelUid
this.update()
this.addEventListener("stdout", (data) => {
this.bytesReceived += data.length
if(this.log){
console.log(`Container ${this.name}: ${data}`)
}
const fixedData = new Uint8Array(data);
this.terminal.write(new TextDecoder().decode(fixedData));
})
this.ws = new WebSocket(`/container/sock/${channelUid}.json`)
this.ws.binaryType = "arraybuffer"; // Support binary data
this.ws.onmessage = (event) => {
this.emit("stdout", event.data)
}
this.ws.onopen = () => {
this.refresh()
}
window.container = this
}
async start(){
const result = await app.rpc.startContainer(this.channelUid)
await this.refresh()
return result && this.status == 'running'
}
async stop(){
const result = await app.rpc.stopContainer(this.channelUid)
await this.refresh()
return result && this.status == 'stopped'
}
async write(data){
await this.ws.send(data)
this.bytesSent += data.length
return true
}
async update(){
const container = await app.rpc.getContainer(this.channelUid)
this.status = container["status"]
this.cpus = container["cpus"]
this.memory = container["memory"]
this.image = container["image"]
this.name = container["name"]
}
}
/*
window.getContainer = function(){
return new Container(app.channelUid)
}*/

View File

@ -0,0 +1,150 @@
class DumbTerminal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
--terminal-bg: #111;
--terminal-fg: #0f0;
--terminal-accent: #0ff;
--terminal-font: monospace;
display: block;
background: var(--terminal-bg);
color: var(--terminal-fg);
font-family: var(--terminal-font);
padding: 1rem;
border-radius: 8px;
overflow-y: auto;
max-height: 500px;
}
.output {
white-space: pre-wrap;
margin-bottom: 1em;
}
.input-line {
display: flex;
}
.prompt {
color: var(--terminal-accent);
margin-right: 0.5em;
}
input {
background: transparent;
border: none;
color: var(--terminal-fg);
outline: none;
width: 100%;
font-family: inherit;
font-size: inherit;
}
dialog {
border: none;
background: transparent;
}
.dialog-backdrop {
background: rgba(0, 0, 0, 0.8);
padding: 2rem;
}
</style>
<div class="output" id="output"></div>
<div class="input-line">
<span class="prompt">&gt;</span>
<input type="text" id="input" autocomplete="off" autofocus />
</div>
`;
this.outputEl = this.shadowRoot.getElementById("output");
this.inputEl = this.shadowRoot.getElementById("input");
this.history = [];
this.historyIndex = -1;
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
}
onKeyDown(event) {
const value = this.inputEl.value;
switch (event.key) {
case "Enter":
this.executeCommand(value);
this.history.push(value);
this.historyIndex = this.history.length;
this.inputEl.value = "";
break;
case "ArrowUp":
if (this.historyIndex > 0) {
this.historyIndex--;
this.inputEl.value = this.history[this.historyIndex];
}
break;
case "ArrowDown":
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.inputEl.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.inputEl.value = "";
}
break;
}
}
executeCommand(command) {
const outputLine = document.createElement("div");
outputLine.textContent = `> ${command}`;
this.outputEl.appendChild(outputLine);
const resultLine = document.createElement("div");
resultLine.textContent = this.mockExecute(command);
this.outputEl.appendChild(resultLine);
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
mockExecute(command) {
switch (command.trim()) {
case "help":
return "Available commands: help, clear, date";
case "date":
return new Date().toString();
case "clear":
this.outputEl.innerHTML = "";
return "";
default:
return `Unknown command: ${command}`;
}
}
/**
* Static method to create a modal dialog with the terminal
* @returns {HTMLDialogElement}
*/
static createModal() {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<div class="dialog-backdrop">
<web-terminal></web-terminal>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
return dialog;
}
}
customElements.define("web-terminal", WebTerminal);

View File

@ -1,16 +1,15 @@
export class EventHandler {
constructor() {
this.subscribers = {};
}
constructor() {
this.subscribers = {};
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
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));
}
}
emit(type, ...data) {
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
}
}

9
src/snek/static/fa640.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,28 +2,25 @@
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
// MIT License
class FancyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.url = null;
this.type = "button";
this.value = null;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.url = null;
this.type = "button";
this.value = null;
}
connectedCallback() {
this.container = document.createElement('span');
let size = this.getAttribute('size');
console.info({ GG: size });
size = size === 'auto' ? '1%' : '33%';
connectedCallback() {
this.container = document.createElement("span");
let size = this.getAttribute("size");
console.info({ GG: size });
size = size === "auto" ? "1%" : "33%";
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
:root {
width: 100%;
--width: 100%;
@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
}
`;
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement('button');
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement("button");
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.url = this.getAttribute('url');
this.url = this.getAttribute("url");
this.value = this.getAttribute('value');
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
this.buttonElement.addEventListener("click", () => {
if(this.url == 'submit'){
this.closest('form').submit()
return
}
if (this.url === "/back" || this.url === "/back/") {
window.history.back();
} else if (this.url) {
window.location = this.url;
}
});
}
this.value = this.getAttribute("value");
this.buttonElement.appendChild(
document.createTextNode(this.getAttribute("text")),
);
this.buttonElement.addEventListener("click", () => {
if (this.url == "submit") {
this.closest("form").submit();
return;
}
if (this.url === "/back" || this.url === "/back/") {
window.history.back();
} else if (this.url) {
window.location = this.url;
}
});
}
}
customElements.define("fancy-button", FancyButton);

View File

@ -0,0 +1,41 @@
.file-manager {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
padding: 20px;
background: #111;
color: #ddd;
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
border-radius: 8px;
}
.file-tile {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
text-align: center;
padding: 10px;
transition: transform 0.2s;
}
.file-tile:hover {
transform: translateY(-5px);
}
.file-icon {
font-size: 40px;
margin-bottom: 10px;
color: #888;
}
.file-name {
font-size: 14px;
overflow-wrap: break-word;
}
.file-tile img {
max-width: 80%;
height: auto;
margin-bottom: 10px;
border-radius: 4px;
}

View File

@ -0,0 +1,116 @@
/* A <file-browser> custom element that talks to /api/files */
class FileBrowser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset
this.limit = 40; // items per request
}
connectedCallback() {
this.path = this.getAttribute("path") || "";
this.renderShell();
this.load();
}
// ---------- UI scaffolding -------------------------------------------
renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }
nav { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }
button { padding:.35rem .65rem; border:none; border-radius:4px; background:#f05a28; color:#fff; cursor:pointer; font:inherit; }
button:disabled { background:#999; cursor:not-allowed; }
.crumb { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }
.tile { border:1px solid #f05a28; border-radius:8px; padding:.5rem; background:#000000; text-align:center; cursor:pointer; transition:box-shadow .2s ease; }
.tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }
img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }
.icon { font-size:48px; line-height:90px; }
</style>
<nav>
<button id="up">⬅️ Up</button>
<span class="crumb" id="crumb"></span>
</nav>
<div class="grid" id="grid"></div>
<nav>
<button id="prev">Prev</button>
<button id="next">Next</button>
</nav>
`;
this.shadowRoot
.getElementById("up")
.addEventListener("click", () => this.goUp());
this.shadowRoot.getElementById("prev").addEventListener("click", () => {
if (this.offset > 0) {
this.offset -= this.limit;
this.load();
}
});
this.shadowRoot.getElementById("next").addEventListener("click", () => {
this.offset += this.limit;
this.load();
});
}
// ---------- Networking ----------------------------------------------
async load() {
const r = await fetch(
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
);
if (!r.ok) {
console.error(await r.text());
return;
}
const data = await r.json();
this.renderTiles(data.items);
this.updateNav(data.pagination);
}
// ---------- Rendering -------------------------------------------------
renderTiles(items) {
const grid = this.shadowRoot.getElementById("grid");
grid.innerHTML = "";
items.forEach((item) => {
const tile = document.createElement("div");
tile.className = "tile";
if (item.type === "directory") {
tile.innerHTML = `<div class="icon">đź“‚</div><div>${item.name}</div>`;
tile.addEventListener("click", () => {
this.path = item.path;
this.offset = 0;
this.load();
});
} else {
if (item.mimetype?.startsWith("image/")) {
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
} else {
tile.innerHTML = `<div class="icon">đź“„</div><div>${item.name}</div>`;
}
tile.addEventListener("click", () => window.open(item.url, "_blank"));
}
grid.appendChild(tile);
});
}
// ---------- Navigation + pagination ----------------------------------
updateNav({ offset, limit, total }) {
this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
this.shadowRoot.getElementById("prev").disabled = offset === 0;
this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
this.shadowRoot.getElementById("up").disabled = this.path === "";
}
goUp() {
if (!this.path) return;
this.path = this.path.split("/").slice(0, -1).join("/");
this.offset = 0;
this.load();
}
}
customElements.define("file-manager", FileBrowser);

View File

@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
}
set value(val) {
val = val ?? '';
val = val ?? "";
this.inputElement.value = val;
this.inputElement.setAttribute("value", val);
}
@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.container = document.createElement('div');
this.styleElement = document.createElement('style');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
h1 {
@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
if (this.inputElement == null && this.field) {
this.inputElement = document.createElement(this.field.tag);
if (this.field.tag === 'button' && this.field.value === "submit") {
if (this.field.tag === "button" && this.field.value === "submit") {
this.action = this.field.value;
}
this.inputElement.name = this.field.name;
@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
const me = this;
this.inputElement.addEventListener("keyup", (e) => {
if (e.key === 'Enter') {
const event = new CustomEvent("change", {detail: me, bubbles: true});
if (e.key === "Enter") {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
me.dispatchEvent(new Event("submit"));
} else if (me.field.value !== e.target.value) {
const event = new CustomEvent("change", {detail: me, bubbles: true});
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
}
});
this.inputElement.addEventListener("click", (e) => {
const event = new CustomEvent("click", {detail: me, bubbles: true});
const event = new CustomEvent("click", { detail: me, bubbles: true });
me.dispatchEvent(event);
});
this.inputElement.addEventListener("blur", (e) => {
const event = new CustomEvent("change", {detail: me, bubbles: true});
me.dispatchEvent(event);
}, true);
this.inputElement.addEventListener(
"blur",
(e) => {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
},
true,
);
this.container.appendChild(this.inputElement);
}
@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
return;
}
this.inputElement.setAttribute("type", this.field.type ?? 'input');
this.inputElement.setAttribute("name", this.field.name ?? '');
this.inputElement.setAttribute("type", this.field.type ?? "input");
this.inputElement.setAttribute("name", this.field.name ?? "");
if (this.field.text != null) {
this.inputElement.innerText = this.field.text;
@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
this.inputElement.removeAttribute("required");
}
if (!this.footerElement) {
this.footerElement = document.createElement('div');
this.footerElement.style.clear = 'both';
this.footerElement = document.createElement("div");
this.footerElement.style.clear = "both";
this.container.appendChild(this.footerElement);
}
}
}
customElements.define('generic-field', GenericField);
customElements.define("generic-field", GenericField);
class GenericForm extends HTMLElement {
fields = {};
@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
@ -281,27 +294,29 @@ class GenericForm extends HTMLElement {
}
}`;
this.container = document.createElement('div');
this.container = document.createElement("div");
this.container.appendChild(this.styleElement);
this.container.classList.add("generic-form-container");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
const preloadedForm = this.getAttribute('preloaded-structure');
const preloadedForm = this.getAttribute("preloaded-structure");
if (preloadedForm) {
try {
const form = JSON.parse(preloadedForm);
this.constructForm(form)
this.constructForm(form);
} catch (error) {
console.error(error, preloadedForm);
}
}
const url = this.getAttribute('url');
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url);
fullUrl.searchParams.set("url", url);
}
this.loadForm(fullUrl.toString());
} else {
@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
let hasAutoFocus = Object.keys(this.fields).length !== 0;
fields.sort((a, b) => a.index - b.index);
fields.forEach(field => {
const updatingField = field.name in this.fields
fields.forEach((field) => {
const updatingField = field.name in this.fields;
this.fields[field.name] ??= document.createElement('generic-field');
this.fields[field.name] ??= document.createElement("generic-field");
const fieldElement = this.fields[field.name];
@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
window.location.pathname = saveResult.redirect_url;
}
}
})
});
}
});
} catch (error) {
@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
await this.constructForm(await response.json());
@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
const url = this.getAttribute("url");
let response = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({"action": "validate", "form": this.form})
body: JSON.stringify({ action: "validate", form: this.form }),
});
const form = await response.json();
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
if (!this.form.fields[field.name]) {
return;
}
@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
this.fields[field.name].setAttribute("field", field);
this.fields[field.name].updateAttributes();
});
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
this.fields[field.name].setErrors(field.errors);
});
return form['is_valid'];
return form["is_valid"];
}
async submit() {
const url = this.getAttribute("url");
const response = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({"action": "submit", "form": this.form})
body: JSON.stringify({ action: "submit", form: this.form }),
});
return await response.json();
}
}
customElements.define('generic-form', GenericForm);
customElements.define("generic-form", GenericForm);

View File

@ -7,48 +7,50 @@
// MIT License
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute('url');
if (!url.startsWith("https")) {
url = "https://" + url;
}
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url);
}
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute("url");
if (!url.startsWith("https")) {
url = "https://" + url;
}
if (url) {
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set("url", url);
}
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement('div');
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement("div");
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
}
customElements.define('html-frame', HTMLFrame);
customElements.define("html-frame", HTMLFrame);

Binary file not shown.

After

(image error) Size: 1.3 MiB

Binary file not shown.

After

(image error) Size: 1.2 MiB

Binary file not shown.

After

(image error) Size: 14 KiB

Binary file not shown.

After

(image error) Size: 17 KiB

Binary file not shown.

After

(image error) Size: 1.0 KiB

Binary file not shown.

After

(image error) Size: 25 KiB

Binary file not shown.

After

(image error) Size: 40 KiB

Binary file not shown.

After

(image error) Size: 1.8 KiB

Binary file not shown.

After

(image error) Size: 79 KiB

Binary file not shown.

After

(image error) Size: 3.2 KiB

Binary file not shown.

After

(image error) Size: 132 KiB

Binary file not shown.

After

(image error) Size: 117 KiB

Binary file not shown.

After

(image error) Size: 5.0 KiB

Binary file not shown.

After

(image error) Size: 5.9 KiB

Binary file not shown.

After

(image error) Size: 177 KiB

Binary file not shown.

After

(image error) Size: 9.0 KiB

Binary file not shown.

After

(image error) Size: 1.3 MiB

View File

@ -1,30 +1,58 @@
{
"id": "snek",
"name": "Snek",
"description": "Danger noodle",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"theme_color": "#000000",
"background_color": "#000000",
"related_applications": [],
"prefer_related_applications": false,
"screenshots": [],
"dir": "ltr",
"lang": "en-US",
"launch_path": "/web.html",
"short_name": "Snek",
"start_url": "/web.html",
"icons": [
{
"src": "/image/snek192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
"id": "snek",
"name": "Snek",
"short_name": "Snek",
"description": "Snek Software Development Community",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/web.html",
"theme_color": "#000000",
"background_color": "#000000",
"dir": "ltr",
"lang": "en-US",
"icons": [
{
"src": "/image/snek_logo_32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "/image/snek_logo_64x64.png",
"type": "image/png",
"sizes": "64x64"
},
{
"src": "/image/snek_logo_128x128.png",
"type": "image/png",
"sizes": "128x128"
},
{
"src": "/image/snek_logo_144x144.png",
"type": "image/png",
"sizes": "144x144"
},
{
"src": "/image/snek_logo_192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek_logo_256x256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "/image/snek_logo_512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/image/snek_logo_1024x1024.png",
"type": "image/png",
"sizes": "1024x1024"
}
],
"related_applications": [],
"prefer_related_applications": false
}

View File

@ -12,22 +12,22 @@
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add('html_frame');
const url = this.getAttribute('url');
this.container.classList.add("html_frame");
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith('/')
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + '/http-get');
if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = 'No source URL!';
this.container.textContent = "No source URL!";
}
}
@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
}
}
customElements.define('markdown-frame', HTMLFrame);
customElements.define("markdown-frame", HTMLFrame);

View File

@ -4,7 +4,6 @@
// No external libraries or dependencies are used other than standard web components.
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@ -13,20 +12,19 @@
//
// 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.
class TileGridElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.gridId = this.getAttribute('grid');
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.gridId = this.getAttribute("grid");
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
console.log('connected');
this.styleElement = document.createElement('style');
this.styleElement.textContent = `
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.grid {
padding: 10px;
display: flex;
@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
transform: scale(1.1);
}
`;
this.component.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('gallery');
this.component.appendChild(this.container);
}
this.component.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("gallery");
this.component.appendChild(this.container);
}
addImage(src) {
const item = document.createElement('img');
item.src = src;
item.classList.add('tile');
item.style.width = '100px';
item.style.height = '100px';
this.container.appendChild(item);
}
addImage(src) {
const item = document.createElement("img");
item.src = src;
item.classList.add("tile");
item.style.width = "100px";
item.style.height = "100px";
this.container.appendChild(item);
}
addImages(srcs) {
srcs.forEach(src => this.addImage(src));
}
addImages(srcs) {
srcs.forEach((src) => this.addImage(src));
}
addElement(element) {
element.classList.add('tile');
this.container.appendChild(element);
}
addElement(element) {
element.classList.add("tile");
this.container.appendChild(element);
}
}
class UploadButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
window.u = this;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
window.u = this;
}
get gridSelector() {
return this.getAttribute('grid');
}
grid = null;
get gridSelector() {
return this.getAttribute("grid");
}
grid = null;
addImages(urls) {
this.grid.addImages(urls);
}
addImages(urls) {
this.grid.addImages(urls);
}
connectedCallback() {
console.log('connected');
this.styleElement = document.createElement('style');
this.styleElement.textContent = `
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.upload-button {
display: flex;
flex-direction: column;
@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
background-color: #999;
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('upload-button');
this.shadowRoot.appendChild(this.container);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.addEventListener('change', (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (e) => {
urls.push(e.target.result);
if (urls.length === files.length) {
this.addImages(urls);
}
};
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement('label');
label.textContent = 'Upload Images';
label.appendChild(input);
this.container.appendChild(label);
}
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("upload-button");
this.shadowRoot.appendChild(this.container);
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.addEventListener("change", (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (e) => {
urls.push(e.target.result);
if (urls.length === files.length) {
this.addImages(urls);
}
};
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement("label");
label.textContent = "Upload Images";
label.appendChild(input);
this.container.appendChild(label);
}
}
customElements.define('upload-button', UploadButton);
customElements.define('tile-grid', TileGridElement);
customElements.define("upload-button", UploadButton);
customElements.define("tile-grid", TileGridElement);
class MeniaUploadElement extends HTMLElement {
constructor(){
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement("div");
alert('aaaa');
this.shadowRoot.appendChild(this.component);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
alert("aaaa");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = '100%';
this.component.style.backgroundColor = 'blue';
this.shadowRoot.appendChild(this.container);
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = "100%";
this.component.style.backgroundColor = "blue";
this.shadowRoot.appendChild(this.container);
this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = 'red';
this.tileElement.style.height = '100%';
this.component.appendChild(this.tileElement);
this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = "red";
this.tileElement.style.height = "100%";
this.component.appendChild(this.tileElement);
this.uploadButton = document.createElement('upload-button');
this.component.appendChild(this.uploadButton);
}
this.uploadButton = document.createElement("upload-button");
this.component.appendChild(this.uploadButton);
}
}
customElements.define('menia-upload', MeniaUploadElement);
customElements.define("menia-upload", MeniaUploadElement);

View File

@ -3,7 +3,7 @@
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
//
//
//
// MIT License
// Permission is hereby granted, free of charge, to any person obtaining a copy
@ -22,23 +22,22 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
class MessageListManagerElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
async connectedCallback() {
const channels = await app.rpc.getChannels();
channels.forEach(channel => {
const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList);
});
}
async connectedCallback() {
const channels = await app.rpc.getChannels();
channels.forEach((channel) => {
const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList);
});
}
}
customElements.define("message-list-manager", MessageListManagerElement);
customElements.define("message-list-manager", MessageListManagerElement);

View File

@ -5,166 +5,111 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "../app.js";
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text", (data) => {
this.updateMessageText(data.uid, data);
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
class MessageListElement extends HTMLElement {
static get observedAttributes() {
return ["messages"];
this.items = [];
}
connectedCallback() {
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const urlObj = new URL(img.currentSrc || img.src)
urlObj.searchParams.delete("width");
urlObj.searchParams.delete("height");
const fullImg = document.createElement('img');
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
})
}
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)
);
}
isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom"));
}
scrollToBottom(force) {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
setTimeout(() => {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
},200)
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
if (!messageDiv) {
return;
}
messages = [];
room = null;
url = null;
container = null;
messageEventSchedule = null;
observer = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
}
triggerGlow(uid,color) {
app.starField.glowColor(color)
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el) => {
const div = el.closest("a");
if (el.href.indexOf(uid) != -1) {
lastElement = el;
}
});
if (lastElement) {
lastElement.classList.add("glow");
setTimeout(() => {
lastElement.classList.remove("glow");
}, 1000);
}
}
linkifyText(text) {
const urlRegex = /https?:\/\/[^\s]+/g;
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
}
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = "";
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
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;
}
createElement(message) {
const element = document.createElement("div");
element.dataset.uid = message.uid;
element.dataset.color = message.color;
element.dataset.channel_uid = message.channel_uid;
element.dataset.user_nick = message.user_nick;
element.dataset.created_at = message.created_at;
element.dataset.user_uid = message.user_uid;
element.dataset.message = message.message;
element.classList.add("message");
if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
element.classList.add("switch-user");
}
const avatar = document.createElement("div");
avatar.classList.add("avatar");
avatar.classList.add("no-select");
avatar.style.backgroundColor = message.color;
avatar.style.color = "black";
avatar.innerText = message.user_nick[0];
const messageContent = document.createElement("div");
messageContent.classList.add("message-content");
const author = document.createElement("div");
author.classList.add("author");
author.style.color = message.color;
author.textContent = message.user_nick;
const text = document.createElement("div");
text.classList.add("text");
if (message.html) text.innerHTML = message.html;
const time = document.createElement("div");
time.classList.add("time");
time.dataset.created_at = message.created_at;
time.textContent = this.timeDescription(message.created_at);
messageContent.appendChild(author);
messageContent.appendChild(text);
messageContent.appendChild(time);
element.appendChild(avatar);
element.appendChild(messageContent);
message.element = element;
return element;
}
addMessage(message) {
const obj = new models.Message(
message.uid,
message.channel_uid,
message.user_uid,
message.user_nick,
message.color,
message.message,
message.html,
message.created_at,
message.updated_at
);
const element = this.createElement(obj);
this.messages.push(obj);
this.container.appendChild(element);
this.messageEventSchedule.delay(() => {
this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
});
return obj;
}
scrollBottom() {
this.container.scrollTop = this.container.scrollHeight;
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.component.classList.add("chat-messages");
this.container = document.createElement('div');
this.component.appendChild(this.container);
this.messageEventSchedule = new Schedule(500);
this.messages = [];
this.channel_uid = this.getAttribute("channel");
app.addEventListener(this.channel_uid, (data) => {
this.addMessage(data);
});
this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
this.timeUpdateInterval = setInterval(() => {
this.messages.forEach((message) => {
const newText = this.timeDescription(message.created_at);
if (newText != message.element.innerText) {
message.element.querySelector(".time").innerText = newText;
}
});
}, 30000);
}
//this.insertAdjacentHTML("beforeend", html);
}
}
customElements.define('message-list', MessageListElement);
customElements.define("message-list", MessageList);

View File

@ -7,20 +7,30 @@
// MIT License
class MessageModel {
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
this.uid = uid
this.message = message
this.html = html
this.user_uid = user_uid
this.user_nick = user_nick
this.color = color
this.channel_uid = channel_uid
this.created_at = created_at
this.updated_at = updated_at
this.element = null
}
constructor(
uid,
channel_uid,
user_uid,
user_nick,
color,
message,
html,
created_at,
updated_at,
) {
this.uid = uid;
this.message = message;
this.html = html;
this.user_uid = user_uid;
this.user_nick = user_nick;
this.color = color;
this.channel_uid = channel_uid;
this.created_at = created_at;
this.updated_at = updated_at;
this.element = null;
}
}
const models = {
Message: MessageModel
}
Message: MessageModel,
};

View File

View File

@ -1,30 +1,57 @@
this.onpush = (event) => {
console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc.
};
navigator.serviceWorker
export const registerServiceWorker = async (silent = false) => {
try {
const serviceWorkerRegistration = await navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
(pushSubscription) => {
const subscriptionObject = {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: pushSubscription.getKey('p256dh'),
auth: pushSubscription.getKey('auth'),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
};
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
// The push subscription details needed by the application
// server are now available, and can be sent to it using,
// for example, the fetch() API.
},
(error) => {
console.error(error);
},
);
});
await serviceWorkerRegistration.update()
await navigator.serviceWorker.ready
const keyResponse = await fetch('/push.json')
const keyData = await keyResponse.json()
const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0))
const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true, applicationServerKey: publicKey,
})
const subscriptionObject = {
...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings,
};
console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject);
const response = await fetch('/push.json', {
method: 'POST', headers: {
'Content-Type': 'application/json',
}, body: JSON.stringify(subscriptionObject),
})
if (!response.ok) {
throw new Error('Bad status code from server.');
}
const responseData = await response.json();
console.log('Registration response', responseData);
} catch (error) {
console.error("Error registering service worker:", error);
if (!silent) {
alert("Registering push notifications failed. Please check your browser settings and try again.\n\n" + error);
}
}
}
window.registerNotificationsServiceWorker = () => {
return Notification.requestPermission().then((permission) => {
if (permission === "granted") {
console.log("Permission was granted");
return registerServiceWorker();
} else if (permission === "denied") {
console.log("Permission was denied");
} else {
console.log("Permission was dismissed");
}
});
};
registerServiceWorker(true).catch(console.error);

179
src/snek/static/sandbox.css Normal file
View File

@ -0,0 +1,179 @@
:root {
--star-color: white;
--background-color: black;
}
body.day {
--star-color: #444;
--background-color: #e6f0ff;
}
body.night {
--star-color: white;
--background-color: black;
}
body {
margin: 0;
overflow: hidden;
background-color: var(--background-color);
transition: background-color 0.5s;
}
.star {
position: absolute;
border-radius: 50%;
background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out;
z-index: -1;
}
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
#themeToggle {
position: absolute;
top: 10px;
left: 10px;
padding: 8px 12px;
font-size: 14px;
z-index: 1000;
}
.star.special {
box-shadow: 0 0 10px 3px gold;
transform: scale(1.4);
z-index: 10;
}
.star-tooltip {
position: absolute;
font-size: 12px;
color: white;
font-family: sans-serif;
pointer-events: none;
z-index: 9999;
white-space: nowrap;
text-shadow: 1px 1px 2px black;
display: none;
padding: 2px 6px;
}
.star-popup {
position: absolute;
max-width: 300px;
color: #fff;
font-family: sans-serif;
font-size: 14px;
z-index: 10000;
text-shadow: 1px 1px 3px black;
display: none;
padding: 10px;
border-radius: 12px;
}
.star:hover {
cursor: pointer;
}
.star-popup {
position: absolute;
max-width: 300px;
background: white;
color: black;
padding: 15px;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
z-index: 10000;
font-family: sans-serif;
font-size: 14px;
display: none;
}
.star-popup h3 {
margin: 0 0 5px;
font-size: 16px;
}
.star-popup button {
margin-top: 10px;
}
.demo-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3em;
color: white;
font-family: 'Segoe UI', sans-serif;
font-weight: 300;
text-align: center;
text-shadow: 0 0 20px rgba(0,0,0,0.8);
z-index: 9999;
opacity: 0;
transition: opacity 0.6s ease;
max-width: 80vw;
pointer-events: none;
}
@keyframes demoFadeIn {
from {
opacity: 0;
transform: translate(-50%, -60%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes demoPulse {
0% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
30% {
box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
}
.demo-highlight {
animation: demoPulse 1.5s ease-out;
font-weight: bold;
position: relative;
z-index: 9999;
}
.star-notify-container {
position: fixed;
top: 50px;
right: 20px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.star-notify {
opacity: 0;
background: transparent;
padding: 5px 10px;
color: white;
font-weight: 300;
text-shadow: 0 0 10px rgba(0,0,0,0.7);
transition: opacity 0.5s ease, transform 0.5s ease;
transform: translateY(-10px);
font-family: 'Segoe UI', sans-serif;
}

View File

@ -51,4 +51,4 @@ export class Schedule {
me.timeOutCount = 0;
}, this.msDelay);
}
}
}

View File

@ -1,64 +1,64 @@
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
// Subscribe to Push Notifications
async function subscribeUser() {
const registration = await navigator.serviceWorker.register('/service-worker.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
// Send subscription to your backend
await fetch('/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json'
}
});
}
// Service Worker (service-worker.js)
self.addEventListener('push', event => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.message,
icon: data.icon
});
});
/*
self.addEventListener("install", (event) => {
console.log("Service worker installed");
console.log("Service worker installing...");
event.waitUntil(
caches.open("snek-cache").then((cache) => {
return cache.addAll([]);
})
);
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.registration?.navigationPreload.enable());
});
self.addEventListener("push", (event) => {
if (!(self.Notification && self.Notification.permission === "granted")) {
return;
}
console.log("Received a push message", event);
self.addEventListener("push", (event) => {
if (!self.Notification || self.Notification.permission !== "granted") {
console.log("Notification permission not granted");
return;
}
const data = event.data?.json() ?? {};
const title = data.title || "Something Has Happened";
const message =
data.message || "Here's something you might want to check out.";
const icon = "images/new-notification.png";
const data = event.data?.json() ?? {};
console.log("Received a push message", event, data);
event.waitUntil(self.registration.showNotification(title, {
body: message,
tag: "simple-push-demo-notification",
icon,
}));
const title = data.title || "Something Has Happened";
const message =
data.message || "Here's something you might want to check out.";
const icon = data.icon || "/image/snek512.png";
const notificationSettings = data.notificationSettings || {};
console.log("Showing message", title, message, icon);
const reg = self.registration.showNotification(title, {
body: message,
tag: "message-received",
icon,
badge: icon,
...notificationSettings,
data,
}).then(e => console.log("Showing notification", e)).catch(console.error);
event.waitUntil(reg);
});
self.addEventListener("notificationclick", (event) => {
console.log("Notification click Received.", event);
event.notification.close();
event.waitUntil(clients.openWindow(
"https://snek.molodetz.nl",));
});*/
event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`));
});
self.addEventListener("notificationclose", (event) => {
console.log("Notification closed", event);
})
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
// console.log("Found response in cache: ", response);
return response;
}
return fetch(event.request);
})
);
})

View File

@ -1,137 +1,151 @@
import {EventHandler} from "./event-handler.js";
import { EventHandler } from "./event-handler.js";
export class Socket extends EventHandler {
/**
* @type {URL}
*/
url
/**
* @type {WebSocket|null}
*/
ws = null
/**
* @type {URL}
*/
url;
/**
* @type {WebSocket|null}
*/
ws = null;
/**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/
connection = null
/**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/
connection = null;
shouldReconnect = true;
shouldReconnect = true;
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
_debug = false;
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
get isConnecting() {
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
}
constructor() {
super();
this.url = new URL("/rpc.ws", window.location.origin);
this.url.protocol = this.url.protocol.replace("http", "ws");
this.connect();
}
connect() {
if (this.ws) {
return this.connection.promise;
}
get isConnecting() {
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
if (!this.connection || this.connection.resolved) {
this.connection = Promise.withResolvers();
}
constructor() {
super();
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => {
this.connection.resolved = true;
this.connection.resolve(this);
this.emit("connected");
});
this.url = new URL('/rpc.ws', window.location.origin);
this.url.protocol = this.url.protocol.replace('http', 'ws');
this.connect()
}
connect() {
if (this.ws) {
return this.connection.promise;
this.ws.addEventListener("close", () => {
console.log("Connection closed");
this.disconnect();
});
this.ws.addEventListener("error", (e) => {
console.error("Connection error", e);
this.disconnect();
});
this.ws.addEventListener("message", (e) => {
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
console.error("Binary data not supported");
} else {
try {
this.onData(JSON.parse(e.data));
} catch (e) {
console.error("Failed to parse message", e);
}
}
});
}
if (!this.connection || this.connection.resolved) {
this.connection = Promise.withResolvers()
}
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => {
this.connection.resolved = true;
this.connection.resolve(this);
this.emit("connected");
});
this.ws.addEventListener("close", () => {
console.log("Connection closed");
this.disconnect()
})
this.ws.addEventListener("error", (e) => {
console.error("Connection error", e);
this.disconnect()
})
this.ws.addEventListener("message", (e) => {
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
console.error("Binary data not supported");
} else {
try {
this.onData(JSON.parse(e.data));
} catch (e) {
console.error("Failed to parse message", e);
}
}
})
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(data);
}
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);
}
if (data.callId) {
this.emit(data.callId, data.data);
}
disconnect() {
this.ws?.close();
this.ws = null;
if (this.shouldReconnect) setTimeout(() => {
console.log("Reconnecting");
return this.connect();
}, 0);
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
if (!data["event"]) this.emit("channel-message", data);
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
this.emit("data", data.data);
if (data["event"]) {
this.emit(data.event, data.data);
}
}
get client() {
const me = this;
return new Proxy({}, {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
};
},
});
}
disconnect() {
this.ws?.close();
this.ws = null;
generateCallId() {
return self.crypto.randomUUID();
}
if (this.shouldReconnect)
setTimeout(() => {
console.log("Reconnecting");
this.emit("reconnecting");
return this.connect();
}, 0);
}
async sendJson(data) {
await this.connect().then(api => {
api.ws.send(JSON.stringify(data));
});
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
}
async call(method, ...args) {
const call = {
callId: this.generateCallId(),
method,
args,
};
const me = this
return new Promise((resolve) => {
me.addEventListener(call.callId, data => resolve(data));
me.sendJson(call);
});
}
}
get client() {
const me = this;
return new Proxy(
{},
{
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args);
};
},
},
);
}
generateCallId() {
return self.crypto.randomUUID();
}
async sendJson(data) {
await this.connect().then((api) => {
api.ws.send(JSON.stringify(data));
});
}
async call(method, ...args) {
const call = {
callId: this.generateCallId(),
method,
args,
};
const me = this;
return new Promise((resolve) => {
me.addEventListener(call.callId, (data) => resolve(data));
me.sendJson(call);
});
}
}

View File

@ -61,4 +61,6 @@ div {
body {
justify-content: flex-start;
}
}
}

View File

@ -2,58 +2,62 @@
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
class UploadButtonElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
constructor() {
super();
this.attachShadow({ mode: "open" });
}
chatInput = null;
async uploadFiles() {
const fileInput = this.container.querySelector(".file-input");
const uploadButton = this.container.querySelector(".upload-button");
if (!fileInput.files.length) {
return;
}
chatInput = null
async uploadFiles() {
const fileInput = this.container.querySelector('.file-input');
const uploadButton = this.container.querySelector('.upload-button');
if (!fileInput.files.length) {
return;
}
const files = fileInput.files;
const formData = new FormData();
formData.append('channel_uid', this.channelUid);
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
}
const request = new XMLHttpRequest();
request.open('POST', '/drive.bin', true);
request.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadButton.innerText = `${Math.round(percentComplete)}%`;
}
};
request.onload = function () {
if (request.status === 200) {
uploadButton.innerHTML = '📤';
} else {
alert('Upload failed');
}
};
request.onerror = function () {
alert('Error while uploading.');
};
request.send(formData);
const files = fileInput.files;
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append("files[]", files[i]);
}
channelUid = null
connectedCallback() {
this.styleElement = document.createElement('style');
this.styleElement.innerHTML = `
const request = new XMLHttpRequest();
request.responseType = "json";
request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
request.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadButton.innerText = `${Math.round(percentComplete)}%`;
}
};
const me = this;
request.onload = function () {
if (request.status === 200) {
me.dispatchEvent(
new CustomEvent("uploaded", { detail: request.response }),
);
uploadButton.innerHTML = "📤";
} else {
alert("Upload failed");
}
};
request.onerror = function () {
alert("Error while uploading.");
};
request.send(formData);
const uploadEvent = new Event("upload", {});
this.dispatchEvent(uploadEvent);
}
channelUid = null;
connectedCallback() {
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
body {
font-family: Arial, sans-serif;
display: flex;
@ -94,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
display: none;
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.innerHTML = `
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.innerHTML = `
<div class="upload-container">
<button class="upload-button">
📤
@ -104,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
<input class="hidden-input file-input" type="file" multiple />
</div>
`;
this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute('channel');
this.uploadButton = this.container.querySelector('.upload-button');
this.fileInput = this.container.querySelector('.hidden-input');
this.uploadButton.addEventListener('click', () => {
this.fileInput.click();
});
this.fileInput.addEventListener('change', () => {
this.uploadFiles();
});
}
this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener("click", () => {
this.fileInput.click();
});
this.fileInput.addEventListener("change", () => {
this.uploadFiles();
});
}
}
customElements.define('upload-button', UploadButtonElement);
customElements.define("upload-button", UploadButtonElement);

View File

@ -0,0 +1,28 @@
.user-list__item {
display: flex;
margin-bottom: 1em;
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
}
.user-list__item-avatar {
margin-right: 10px;
border-radius: 50%;
overflow: hidden;
width: 40px;
height: 40px;
display: block;
}
.user-list__item-content {
flex: 1;
}
.user-list__item-name {
font-weight: bold;
}
.user-list__item-text {
margin: 5px 0;
}
.user-list__item-time {
font-size: 0.8em;
color: gray;
}

View File

@ -0,0 +1,59 @@
class UserList extends HTMLElement {
constructor() {
super();
this.users = [];
}
set data(userArray) {
this.users = userArray;
this.render();
}
formatRelativeTime(timestamp) {
const now = new Date();
const msgTime = new Date(timestamp);
const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
} else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
} else {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
}
}
render() {
this.innerHTML = "";
this.users.forEach((user) => {
const html = `
<div class="user-list__item"
data-uid="${user.uid}"
data-color="${user.color}"
data-user_nick="${user.nick}"
data-created_at="${user.created_at}"
data-user_uid="${user.user_uid}">
<a class="user-list__item-avatar" style="background-color: ${user.color}; color: black;" href="/user/${user.uid}.html">
<img width="40px" height="40px" src="/avatar/${user.uid}.svg" alt="${user.nick}">
</a>
<div class="user-list__item-content">
<div class="user-list__item-name" style="color: ${user.color};">${user.nick}</div>
<div class="user-list__item-time" data-created_at="${user.last_ping}">
<a href="/user/${user.uid}.html">profile</a>
<a href="/channel/${user.uid}.html">dm</a>
</div>
</div>
</div>
`;
this.insertAdjacentHTML("beforeend", html);
});
}
}
customElements.define("user-list", UserList);

135
src/snek/sync.py Normal file
View File

@ -0,0 +1,135 @@
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
class BroadCastSocketView:
def __init__(self):
self.ws = None
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
app = request.app
app['broadcast_clients'].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
print(msg.data)
for client in app['broadcast_clients'] if not client == ws:
await client.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
app['broadcast_clients'].remove(ws)
return ws
app = web.Application()
view = DatasetWebSocketView()
app['broadcast_clients'] = []
app.router.add_get('/db', view.handle)
app.router.add_get('/broadcast', sync_view.handle)

View File

@ -13,10 +13,15 @@ class Cache:
self.app = app
self.cache = {}
self.max_items = max_items
self.stats = {}
self.enabled = False
self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
async def get(self, args):
if not self.enabled:
return None
await self.update_stat(args, "get")
try:
self.lru.pop(self.lru.index(args))
except:
@ -29,6 +34,33 @@ class Cache:
# print("Cache hit!", args, flush=True)
return self.cache[args]
async def get_stats(self):
all_ = []
for key in self.lru:
all_.append(
{
"key": key,
"set": self.stats[key]["set"],
"get": self.stats[key]["get"],
"delete": self.stats[key]["delete"],
"value": str(self.serialize(self.cache[key].record)),
}
)
return all_
def serialize(self, obj):
cpy = obj.copy()
cpy.pop("created_at", None)
cpy.pop("deleted_at", None)
cpy.pop("email", None)
cpy.pop("password", None)
return cpy
async def update_stat(self, key, action):
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] = self.stats[key][action] + 1
def json_default(self, value):
# if hasattr(value, "to_json"):
# return value.to_json()
@ -47,8 +79,11 @@ class Cache:
)
async def set(self, args, result):
if not self.enabled:
return
is_new = args not in self.cache
self.cache[args] = result
await self.update_stat(args, "set")
try:
self.lru.pop(self.lru.index(args))
except (ValueError, IndexError):
@ -64,6 +99,9 @@ class Cache:
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
async def delete(self, args):
if not self.enabled:
return
await self.update_stat(args, "delete")
if args in self.cache:
try:
self.lru.pop(self.lru.index(args))

198
src/snek/system/docker.py Normal file
View File

@ -0,0 +1,198 @@
import copy
import json
import yaml
import asyncio
import subprocess
try:
import pty
except Exception as ex:
print("You are not able to run a terminal. See error:")
print(ex)
import os
class ComposeFileManager:
def __init__(self, compose_path="docker-compose.yml",event_handler=None):
self.compose_path = compose_path
self._load()
self.running_instances = {}
self.event_handler = event_handler
def _load(self):
try:
with open(self.compose_path) as f:
self.compose = yaml.safe_load(f) or {}
except FileNotFoundError:
self.compose = {"services": {}}
def _save(self):
with open(self.compose_path, "w") as f:
yaml.dump(self.compose, f, default_flow_style=False)
def list_instances(self):
return list(self.compose.get("services", {}).keys())
async def _create_readers(self, container_name):
instance = await self.get_instance(container_name)
if not instance:
return False
proc = self.running_instances.get(container_name)
if not proc:
return False
async def reader(event_handler,stream):
loop = asyncio.get_event_loop()
while True:
line = await loop.run_in_executor(None,os.read,stream,1024)
if not line:
break
await event_handler(container_name,"stdout",line)
await self.stop(container_name)
asyncio.create_task(reader(self.event_handler,proc['master']))
def create_instance(
self,
name,
image,
command=None,
cpus=None,
memory=None,
ports=None,
volumes=None,
):
service = {
"image": image,
}
service["command"] = command or "tail -f /dev/null"
if cpus or memory:
service["deploy"] = {"resources": {"limits": {}}}
if cpus:
service["deploy"]["resources"]["limits"]["cpus"] = str(cpus)
if memory:
service["deploy"]["resources"]["limits"]["memory"] = str(memory)
if ports:
service["ports"] = [
f"{host}:{container}" for container, host in ports.items()
]
if volumes:
service["volumes"] = volumes
self.compose.setdefault("services", {})[name] = service
self._save()
def remove_instance(self, name):
if name in self.compose.get("services", {}):
del self.compose["services"][name]
self._save()
async def get_instance(self, name):
instance = self.compose.get("services", {}).get(name)
if not instance:
return None
instance = json.loads(json.dumps(instance,default=str))
instance['status'] = await self.get_instance_status(name)
return instance
def duplicate_instance(self, name, new_name):
orig = self.get_instance(name)
if not orig:
raise ValueError(f"No such instance: {name}")
self.compose["services"][new_name] = copy.deepcopy(orig)
self._save()
def update_instance(self, name, **kwargs):
service = self.get_instance(name)
if not service:
raise ValueError(f"No such instance: {name}")
for k, v in kwargs.items():
if v is not None:
service[k] = v
self.compose["services"][name] = service
self._save()
async def get_instance_status(self, name):
"""Asynchronously check the status of a docker-compose service instance."""
if name not in self.list_instances():
return "error"
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "ps", "--services", "--filter", f"status=running",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
running_services = stdout.decode().split()
print(running_services)
return "running" if name in running_services else "stopped"
async def write_stdin(self, name, data):
await self.event_handler(name, "stdin", data)
proc = self.running_instances.get(name)
if not proc:
return False
try:
os.write(proc['master'], data.encode())
return True
except Exception as ex:
print(ex)
await self.stop(name)
return False
async def stop(self, name):
"""Asynchronously stop a container by doing 'docker compose stop [name]'."""
if name not in self.list_instances():
return False
status = await self.get_instance_status(name)
if status != "running":
return True
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "stop", name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
if name in self.running_instances:
del self.running_instances[name]
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to stop {name}: {stderr.decode()}")
if stdout:
await self.event_handler(name,"stdout",stdout)
return stdout.decode(errors="ignore")
await self.event_handler(name,"stdout",stderr)
return stderr.decode(errors="ignore")
async def start(self, name):
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
if name not in self.list_instances():
return False
status = await self.get_instance_status(name)
if name in self.running_instances and status == "running" and self.running_instances.get(name) and self.running_instances.get(name).get('proc').returncode == None:
return True
elif name in self.running_instances:
del self.running_instances[name]
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "up", name, "-d",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout,stderr = await proc.communicate()
if proc.returncode != 0:
print(f"Failed to start {name}: {stderr.decode(errors='ignore')}")
return False
master, slave = pty.openpty()
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "exec", name, "/bin/bash",
stdin=slave,
stdout=slave,
stderr=slave,
)
proc = {'proc': proc, 'master': master, 'slave': slave}
self.running_instances[name] = proc
await self._create_readers(name)
return True

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