Compare commits

...

242 Commits

Author SHA1 Message Date
fadc57a7c7 Update. 2025-09-09 23:40:45 +02:00
cca3946a35 Update channel message. 2025-09-08 06:08:12 +02:00
18be3fdc19 Executor pools. 2025-09-08 01:09:22 +02:00
939e63f244 Executor pools. 2025-09-08 00:59:11 +02:00
b4c267d584 Update. 2025-09-07 02:42:47 +02:00
b9b31a494a Update. 2025-08-31 03:41:41 +02:00
b961954aa1 Update. 2025-08-31 03:27:47 +02:00
84287808c8 Update. 2025-08-31 03:25:26 +02:00
692272e3ca Fixes. 2025-08-31 03:22:00 +02:00
89d639e44e New editor. 2025-08-06 15:33:13 +02:00
e62da0aef1 Bugfix. 2025-08-06 14:18:11 +02:00
3759306e38 Update sentry. 2025-08-06 11:51:58 +02:00
fcd91b4321 Deleted security. 2025-08-03 03:31:08 +02:00
cf32a78ef5 Removed interval. 2025-08-01 02:06:20 +02:00
7c43d957bc Randomized skeep. 2025-08-01 01:59:23 +02:00
cc6a9ef9d3 Randomized skeep. 2025-08-01 01:57:18 +02:00
1babfa0d64 Optional replace.: 2025-07-30 14:49:16 +02:00
ce940b39b8 Optional replace.: 2025-07-30 14:46:10 +02:00
6151fc1dac Optional replace.: 2025-07-30 14:44:25 +02:00
338bdb5932 Optional replace.: 2025-07-30 14:41:23 +02:00
bbcc845c26 Optional replace.: 2025-07-30 14:36:56 +02:00
1c080bc4be Big webdav change. 2025-07-30 03:25:36 +02:00
59b0494328 Update. 2025-07-30 02:51:26 +02:00
6337350b60 Update. 2025-07-26 23:56:24 +02:00
986acfac38 Update. 2025-07-25 17:17:45 +02:00
b27149b5ba Update. 2025-07-25 17:10:03 +02:00
eb1284060a Update attributes. 2025-07-25 17:06:15 +02:00
4266ac1f12 Less stars 2025-07-25 17:06:15 +02:00
6b4709d011 Merge pull request 'Add support for once event listeners and improve event removal' (#67) from BordedDev/snek:feat/once-event-listener into main
Reviewed-on: retoor/snek#67
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:36:50 +02:00
ef8d3068a8 Merge pull request 'Fix some message rendering' (#68) from BordedDev/snek:bugfix/message-list-rendering into main
Reviewed-on: retoor/snek#68
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:35:49 +02:00
BordedDev
a23c14389b Fix some message rendering 2025-07-24 16:14:51 +02:00
BordedDev
6dfd8db0a6 Add support for once event listeners and improve event removal 2025-07-24 15:59:17 +02:00
abce2e03d1 Fix xss. 2025-07-24 01:46:34 +02:00
54d7d5b74e Several updates. 2025-07-24 01:07:33 +02:00
17bb88050a Merge pull request 'Fixed scrolling behavior, reply, cross channel messages and gg navigation' (#66) from BordedDev/snek:bugfix/multiple-issues-with-new-chat into main
Reviewed-on: retoor/snek#66
2025-07-20 19:55:47 +02:00
BordedDev
8c2e20dfe8 Fix reply text 2025-07-20 03:38:18 +02:00
BordedDev
3e2dd7ea04 Moved replay to custom event 2025-07-20 01:11:05 +02:00
BordedDev
70eebefac7 Fixed upsert error when typing 2025-07-19 23:31:23 +02:00
BordedDev
ac47d201d8 Fixed scrolled to bottom check 2025-07-19 00:13:06 +02:00
BordedDev
11e19f48e8 Fix g scroll 2025-07-19 00:00:29 +02:00
BordedDev
5ac49522d9 Fixed scrolling behavior, reply, cross channel messages 2025-07-18 23:57:41 +02:00
f9f1179db5 Updated channel message. 2025-07-18 23:20:46 +02:00
04527c286f Updated save. 2025-07-18 17:49:01 +02:00
e23d6571c8 Updated save. 2025-07-18 17:44:45 +02:00
0c331bbb93 Update. 2025-07-18 00:43:40 +02:00
a2d506cce9 Merge pull request 'maintenance/clean-up-messages' (#65) from BordedDev/snek:maintenance/clean-up-messages into main
Reviewed-on: retoor/snek#65
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-18 00:17:33 +02:00
BordedDev
3a4cf93bcc Fixed some scrolling issues when opening terminal 2025-07-17 23:21:15 +02:00
BordedDev
29b9fce07d Cleaned up message rendering a bit 2025-07-17 02:22:47 +02:00
9a0ba22fe8 That's how we do in the east. 2025-07-14 19:11:46 +02:00
8321761358 Update. 2025-07-12 20:58:19 +02:00
bdee3e844f Fixed some routes. 2025-07-12 03:01:39 +02:00
081d66695c Added forum. 2025-07-12 02:41:57 +02:00
94c5ce4989 WebDav properies support. 2025-07-10 03:25:40 +02:00
6a05b56481 Update. 2025-07-08 23:54:28 +02:00
6f34e96111 Update middleware. 2025-07-08 23:35:35 +02:00
30e8c17000 Merge pull request 'Add support for embedded players' (#64) from BordedDev/snek:feat/support-player into main
Reviewed-on: retoor/snek#64
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-06 01:00:13 +02:00
BordedDev
62619f26b8 Add support for embedded players 2025-07-05 18:00:58 +02:00
3e006e0bec Merge pull request 'Fix untyped/unknown mime file download' (#63) from BordedDev/snek:bugfix/file-download into main
Reviewed-on: retoor/snek#63
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-04 22:29:58 +02:00
3a4c46cc0d Update. 2025-07-04 10:38:08 +02:00
9ba6a28c5c Update. 2025-07-04 09:32:03 +02:00
5c6d837f10 Update. 2025-07-04 09:29:03 +02:00
634aa535de Fixed syntax issue. 2025-07-04 06:03:53 +02:00
8ce607d787 Disable the patch. 2025-07-04 05:50:54 +02:00
85edc95652 Aded not-show notif when window visible 2025-07-04 05:40:19 +02:00
a7d7cb5627 Removed live type. 2025-07-04 05:16:56 +02:00
BordedDev
f3ef9d4052 Fix untyped/unknown mime file download 2025-07-02 01:03:09 +02:00
f77ccd47aa Update. 2025-07-01 10:43:15 +02:00
006bb4e282 Update. 2025-06-29 21:13:30 +02:00
2af8ce9bb3 Update. 2025-06-29 21:10:57 +02:00
d67f9f3f3e Update. 2025-06-29 21:09:03 +02:00
fa89cde581 Update. 2025-06-29 21:06:27 +02:00
c05bd57fe1 Update. 2025-06-29 21:03:33 +02:00
88bb78fb23 Update. 2025-06-29 20:56:44 +02:00
e96cb5bdaa Working perfect. 2025-06-29 20:34:25 +02:00
86844e330e Update tts/ 2025-06-29 20:16:15 +02:00
cf95fea6c3 Update tts/ 2025-06-29 20:08:13 +02:00
c366f8f0d3 Full STT and TTS. 2025-06-29 19:58:40 +02:00
1e6f8b9cd8 Update. 2025-06-29 17:39:38 +02:00
006882cd6f Merge pull request 'Improved site embedding' (#57) from BordedDev/snek:feat/improved-embeding into main
Reviewed-on: retoor/snek#57
2025-06-29 16:24:33 +02:00
ceabf7d73b UPdate. 2025-06-29 16:19:53 +02:00
874dbe1e43 So closegit diff 2025-06-29 00:12:07 +02:00
6e0aa3c46c So closegit diff 2025-06-29 00:04:42 +02:00
ea047411ef Whop. 2025-06-27 17:38:35 +02:00
d21201cdce Added image reply. 2025-06-27 17:36:27 +02:00
c087832b74 Added image reply. 2025-06-27 17:33:23 +02:00
BordedDev
ee11cde835 Added shorts embedding support 2025-06-27 12:10:16 +02:00
BordedDev
cc9fc0eeaf Simplified embedding, added support for height/width and better site name selection
# Conflicts:
#	src/snek/system/template.py
2025-06-27 08:09:44 +02:00
27de7a8b4e Update. 2025-06-25 22:46:07 +02:00
200b0178e7 Update. 2025-06-25 22:41:55 +02:00
9e237a1d98 Upodate. 2025-06-25 21:38:26 +02:00
db3b5a77bc Upodate. 2025-06-25 21:23:31 +02:00
8a6aa33974 Added indexes. 2025-06-25 21:17:37 +02:00
3fc33eaa12 Implemented leetSpeak 2025-06-25 20:59:22 +02:00
f2b35c108c Upodate. 2025-06-25 18:22:16 +02:00
52ddce5625 Upodate. 2025-06-25 18:11:16 +02:00
934f334b91 Upodate. 2025-06-25 18:07:11 +02:00
3130933ea4 Upodate. 2025-06-25 17:54:12 +02:00
481d7e3ab6 UPdate live type. 2025-06-25 17:31:49 +02:00
e76bf15135 Implemented leetSpeak 2025-06-25 09:10:27 +02:00
05192a00cd Merge pull request 'Fix file upload' (#59) from BordedDev/snek:bugfix/file-upload into main
Reviewed-on: retoor/snek#59
2025-06-23 00:43:32 +02:00
BordedDev
0de95e3824 Fix file upload 2025-06-23 00:41:09 +02:00
23055919cd Wieuw 2025-06-21 23:49:04 +02:00
6350a7851c Merge pull request 'Added youtube music url to options for embedding' (#56) from BordedDev/snek:feat/embed-youtube-music into main
Reviewed-on: retoor/snek#56
2025-06-19 14:37:17 +02:00
5b3e12ec4b Merge pull request 'Add missing HTML attributes to whitelist' (#58) from BordedDev/snek:bugfix/missing-attributes into main
Reviewed-on: retoor/snek#58
2025-06-19 14:35:40 +02:00
82dc018676 Merge branch 'main' into bugfix/missing-attributes 2025-06-19 09:33:17 +02:00
BordedDev
40d11dc66a
Clean up 2025-06-19 09:33:05 +02:00
BordedDev
b9a142ba48
Add missing HTML attributes to whitelist 2025-06-19 09:30:33 +02:00
afeb59d0c1 Merge branch 'main' into feat/embed-youtube-music 2025-06-19 08:23:14 +02:00
BordedDev
56ef299c5d
Added youtube music url to options for embedding 2025-06-19 08:11:27 +02:00
fd5d1c3401 Update. 2025-06-15 02:30:57 +02:00
4a6bba7734 Update. 2025-06-15 02:21:49 +02:00
c32d3509b3 Update. 2025-06-15 02:18:45 +02:00
213136b92e Update. 2025-06-15 02:16:08 +02:00
0dc75452d6 Removed ddebuglines. 2025-06-14 18:36:40 +02:00
9d1f6831df Update. 2025-06-14 17:04:14 +02:00
0341e2f2d8 Update. 2025-06-14 15:42:29 +02:00
c4739ff7d3 Update. 2025-06-14 15:20:04 +02:00
cac691e9ff Update. 2025-06-14 13:35:41 +02:00
0e12f7a75c Nice. 2025-06-14 13:08:31 +02:00
3872dafaf1 Nice. 2025-06-14 13:02:53 +02:00
6a905c1948 Nice. 2025-06-14 12:53:40 +02:00
bf576bc0e3 Update. 2025-06-14 12:42:34 +02:00
2c182ad48d Update. 2025-06-14 12:34:26 +02:00
a4d29b9d6f Update. 2025-06-14 09:15:08 +02:00
52538d0181 Update. 2025-06-14 08:31:37 +02:00
75f12c1971 We did do the cool stuff. 2025-06-14 08:24:48 +02:00
a41da84e3f Progress. 2025-06-12 21:59:37 +02:00
c53b930554 Update. 2025-06-12 01:41:05 +02:00
1705575985 Update. 2025-06-12 01:39:22 +02:00
0c0742fce7 Update. 2025-06-12 01:38:45 +02:00
803ad3dfc6 Update. 2025-06-12 00:09:16 +02:00
f965cc4ecd Update. 2025-06-11 20:20:34 +02:00
ad3f46a9ae Update channel. 2025-06-11 17:27:43 +02:00
3ea4918ca2 Update. 2025-06-11 17:13:58 +02:00
26a54848f1 Update. 2025-06-11 01:28:35 +02:00
015337d75c Update signals. 2025-06-11 01:21:33 +02:00
5f06d7e04c Update. 2025-06-11 01:21:21 +02:00
1b2ad3965b Update. 2025-06-10 19:26:52 +02:00
7adb71efe5 Update. 2025-06-10 19:15:31 +02:00
f35fb12856 Update. 2025-06-10 18:25:21 +02:00
0295108e6b Updated paths. 2025-06-10 17:04:50 +02:00
a2fa976065 Update url! 2025-06-10 16:51:40 +02:00
b82db127ae Deployment communication. 2025-06-10 16:44:01 +02:00
f5e807cdbf Perfect sizes! 2025-06-10 16:28:30 +02:00
06f28fd426 Update url! 2025-06-10 15:59:41 +02:00
60afefac96 Perfect sizes! 2025-06-10 15:44:39 +02:00
351fef504c Update. 2025-06-10 15:31:31 +02:00
3a8703d3c6 Update. 2025-06-10 15:31:09 +02:00
1c58165425 Update contianer. 2025-06-10 13:39:35 +02:00
38640d8f75 Update contianer. 2025-06-10 13:37:34 +02:00
bb1f7cdb88 Upate. 2025-06-10 13:18:26 +02:00
4bb1b0997a Update. 2025-06-10 13:04:33 +02:00
f8f1235e60 Toggle container. 2025-06-10 13:00:59 +02:00
fbd72f727a Updated NJET 2025-06-10 11:36:09 +02:00
62eb1060d9 Update. 2025-06-10 11:28:54 +02:00
2fc18801a7 Merge pull request 'Fix heif converter being broken' (#55) from BordedDev/snek:bugfix/fix-pictures-again into main
Reviewed-on: retoor/snek#55
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' (#54) from BordedDev/snek:bugfix/removed-encoding into main
Reviewed-on: retoor/snek#54
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' (#34) from BordedDev/snek:feat/push-notifications into main
Reviewed-on: retoor/snek#34
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?' (#52) from BordedDev/snek:bugfix/msg-finalized-before-update into main
Reviewed-on: retoor/snek#52
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' (#53) from BordedDev/snek:feat/spoilers into main
Reviewed-on: retoor/snek#53
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' (#51) from BordedDev/snek:bugfix/typing-desync into main
Reviewed-on: retoor/snek#51
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' (#50) from BordedDev/snek:bugfix/fix-image-zoom-url into main
Reviewed-on: retoor/snek#50
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.' (#49) from feat/make-database-async into main
Reviewed-on: retoor/snek#49
2025-06-01 00:28:34 +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
126 changed files with 10654 additions and 2432 deletions

2
.gitignore vendored
View File

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

View File

@ -10,3 +10,9 @@ RUN chmod +x r
RUN mv r /usr/local/bin/r RUN mv r /usr/local/bin/r
RUN echo 'root:root' | chpasswd
COPY ./terminal /opt/bootstrap
COPY ./terminal /opt/snek
RUN cp -r /root /opt/bootstrap/root
COPY ./terminal/entry /usr/local/bin/entry

View File

@ -21,6 +21,9 @@ serve: run
run: run:
.venv/bin/snek serve .venv/bin/snek serve
maintenance:
.venv/bin/snek maintenance
install: ubuntu install: ubuntu
python3.12 -m venv .venv python3.12 -m venv .venv
$(PIP) install -e . $(PIP) install -e .

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

@ -38,6 +38,9 @@ dependencies = [
"humanize", "humanize",
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

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,24 +1,81 @@
import click
import uvloop
from aiohttp import web
import asyncio
from snek.app import Application
from IPython import start_ipython
import sqlite3
import pathlib import pathlib
import shutil 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() @click.group()
def cli(): def cli():
pass pass
@cli.command() @cli.command()
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.') def export():
@click.option('--source',default=None, help='Database to initialize if not exists.')
def init(db_path,source): 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(): if source and pathlib.Path(source).exists():
print(f"Copying {source} to {db_path}") print(f"Copying {source} to {db_path}")
shutil.copy2(source,db_path) shutil.copy2(source, db_path)
print("Database initialized.") print("Database initialized.")
return return
@ -33,25 +90,48 @@ def init(db_path,source):
db.close() db.close()
print("Database initialized.") 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() @cli.command()
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application') @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): def shell(db_path):
app = Application(db_path=f"sqlite:///{db_path}") Shell(db_path).run()
start_ipython(argv=[], user_ns={'app': app})
def main(): def main():
try:
import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError:
print("Could not import sentry_sdk")
cli() cli()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,16 +1,21 @@
import asyncio import asyncio
import logging import logging
import pathlib import pathlib
import time import ssl
import uuid import uuid
import signal
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager
import aiohttp_debugtoolbar
from snek import snode from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
import json
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address
import IP2Location
from aiohttp import web from aiohttp import web
from aiohttp_session import ( from aiohttp_session import (
get_session as session_get, get_session as session_get,
@ -21,46 +26,62 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
from snek.sssh import start_ssh_server
from snek.docs.app import Application as DocsApplication
from snek.mapper import get_mappers from snek.mapper import get_mappers
from snek.service import get_services 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 import http
from snek.system.cache import Cache from snek.system.cache import Cache
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
from snek.system.markdown import MarkdownExtension from snek.system.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.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.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
from snek.view.docs import DocsHTMLView, DocsMDView from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveView from snek.view.drive import DriveApiView, DriveView
from snek.view.drive import DriveApiView from snek.view.channel import ChannelDriveApiView
from snek.view.index import IndexView from snek.view.index import IndexView
from snek.view.login import LoginView from snek.view.login import LoginView
from snek.view.logout import LogoutView from snek.view.logout import LogoutView
from snek.view.push import PushView
from snek.view.register import RegisterView from snek.view.register import RegisterView
from snek.view.rpc import RPCView
from snek.view.repository import RepositoryView from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView from snek.view.search_user import SearchUserView
from snek.view.settings.repositories import RepositoriesIndexView from snek.view.container import ContainerView
from snek.view.settings.repositories import RepositoriesCreateView from snek.view.settings.containers import (
from snek.view.settings.repositories import RepositoriesUpdateView ContainersCreateView,
from snek.view.settings.repositories import RepositoriesDeleteView ContainersDeleteView,
ContainersIndexView,
ContainersUpdateView,
)
from snek.view.settings.index import SettingsIndexView from snek.view.settings.index import SettingsIndexView
from snek.view.settings.profile import SettingsProfileView 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.stats import StatsView
from snek.view.status import StatusView from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView from snek.view.upload import UploadView
from snek.view.user import UserView from snek.view.user import UserView
from snek.view.web import WebView from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication from snek.webdav import WebdavApplication
from snek.sgit import GitApplication from snek.forum import setup_forum
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware @web.middleware
@ -70,6 +91,33 @@ async def session_middleware(request, handler):
return response 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 @web.middleware
async def trailing_slash_middleware(request, handler): async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"): if request.path and not request.path.endswith("/"):
@ -79,16 +127,21 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication): class Application(BaseApplication):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
middlewares = [ middlewares = [
stats_middleware,
cors_middleware, cors_middleware,
web.normalize_path_middleware(merge_slashes=True), web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware,
] ]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates") self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static") self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__( super().__init__(
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5 * args,
**kwargs,
) )
session_setup(self, EncryptedCookieStorage(SESSION_KEY)) session_setup(self, EncryptedCookieStorage(SESSION_KEY))
self.tasks = asyncio.Queue() self.tasks = asyncio.Queue()
@ -98,10 +151,12 @@ class Application(BaseApplication):
self.jinja2_env.add_extension(LinkifyExtension) self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension) self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension) self.jinja2_env.add_extension(EmojiExtension)
self.jinja2_env.filters["sanitize"] = sanitize_html
self.time_start = datetime.now() self.time_start = datetime.now()
self.ssh_host = "0.0.0.0" self.ssh_host = "0.0.0.0"
self.ssh_port = 2242 self.ssh_port = 2242
self.setup_router()
self.forum = None
self.ssh_server = None self.ssh_server = None
self.sync_service = None self.sync_service = None
self.executor = None self.executor = None
@ -111,11 +166,24 @@ class Application(BaseApplication):
self.broadcast_service = None self.broadcast_service = None
self.user_availability_service_task = None self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
)
self.on_startup.append(self.prepare_stats)
self.on_startup.append(self.prepare_asyncio) self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database) self.on_startup.append(self.prepare_database)
async def prepare_stats(self, app):
app['stats'] = create_stats_structure()
print("Stats prepared", flush=True)
@property @property
def uptime_seconds(self): def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds() return (datetime.now() - self.time_start).total_seconds()
@ -124,7 +192,7 @@ class Application(BaseApplication):
def uptime(self): def uptime(self):
return self._format_uptime(self.uptime_seconds) return self._format_uptime(self.uptime_seconds)
def _format_uptime(self,seconds): def _format_uptime(self, seconds):
seconds = int(seconds) seconds = int(seconds)
days, seconds = divmod(seconds, 86400) days, seconds = divmod(seconds, 86400)
hours, seconds = divmod(seconds, 3600) hours, seconds = divmod(seconds, 3600)
@ -142,14 +210,16 @@ class Application(BaseApplication):
return ", ".join(parts) return ", ".join(parts)
async def start_user_availability_service(self, app): async def start_user_availability_service(self, app):
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service()) app.user_availability_service_task = asyncio.create_task(
app.services.socket.user_availability_service()
)
async def snode_sync(self, app): async def snode_sync(self, app):
self.sync_service = asyncio.create_task(snode.sync_service(app)) self.sync_service = asyncio.create_task(snode.sync_service(app))
async def start_ssh_server(self, app): async def start_ssh_server(self, app):
app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port) app.ssh_server = await start_ssh_server(app, app.ssh_host, app.ssh_port)
if app.ssh_server: if app.ssh_server:
asyncio.create_task(app.ssh_server.wait_closed()) asyncio.create_task(app.ssh_server.wait_closed())
@ -157,6 +227,10 @@ class Application(BaseApplication):
# app.loop = asyncio.get_running_loop() # app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200) app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor) app.loop.set_default_executor(self.executor)
#for sig in (signal.SIGINT, signal.SIGTERM):
#app.loop.add_signal_handler(
# sig, lambda: asyncio.create_task(self.services.container.shutdown())
#)
async def create_task(self, task): async def create_task(self, task):
await self.tasks.put(task) await self.tasks.put(task)
@ -172,6 +246,7 @@ class Application(BaseApplication):
print(ex) print(ex)
self.db.commit() self.db.commit()
async def prepare_database(self, app): async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL") self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off") self.db.query("PRAGMA syncnorm=off")
@ -186,7 +261,7 @@ class Application(BaseApplication):
except: except:
pass pass
await app.services.drive.prepare_all() await self.services.drive.prepare_all()
self.loop.create_task(self.task_runner()) self.loop.create_task(self.task_runner())
def setup_router(self): def setup_router(self):
@ -198,6 +273,7 @@ class Application(BaseApplication):
show_index=True, show_index=True,
) )
self.router.add_view("/profiler.html", profiler_handler) 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.html", AboutHTMLView)
self.router.add_view("/about.md", AboutMDView) self.router.add_view("/about.md", AboutMDView)
self.router.add_view("/logout.json", LogoutView) self.router.add_view("/logout.json", LogoutView)
@ -208,14 +284,15 @@ class Application(BaseApplication):
self.router.add_view("/settings/index.html", SettingsIndexView) self.router.add_view("/settings/index.html", SettingsIndexView)
self.router.add_view("/settings/profile.html", SettingsProfileView) self.router.add_view("/settings/profile.html", SettingsProfileView)
self.router.add_view("/settings/profile.json", 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("/web.html", WebView)
self.router.add_view("/login.html", LoginView) self.router.add_view("/login.html", LoginView)
self.router.add_view("/login.json", LoginView) self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView) self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView) self.router.add_view("/register.json", RegisterView)
self.router.add_view("/drive/{rel_path:.*}", DriveView) # self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/drive.bin", UploadView) ## self.router.add_view("/drive.bin", UploadView)
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView) # self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView) self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView) self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView) self.router.add_view("/avatar/{uid}.svg", AvatarView)
@ -223,38 +300,64 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView) self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView) self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView) #self.router.add_view(
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView) # "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
#)
#self.router.add_view(
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView
#)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
)
self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)#
self.router.add_view("/channel/{channel}.html", WebView) self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView) self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView) self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView) self.router.add_view("/terminal.html", TerminalView)
self.router.add_view("/drive.json", DriveApiView) #self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView) #self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView) #self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_get("/stats.html", stats_handler)
self.router.add_view("/stats.json", StatsView) self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView) self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView) self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view("/repository/{username}/{repository}/{path:.*}", 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/index.html", RepositoriesIndexView)
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView) self.router.add_view(
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView) "/settings/repositories/create.html", RepositoriesCreateView
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView) )
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/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView) self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView) self.router.add_view(
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView) "/settings/containers/container/{uid}/update.html", ContainersUpdateView
)
self.router.add_view(
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
)
self.webdav = WebdavApplication(self) self.webdav = WebdavApplication(self)
self.git = GitApplication(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) self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request): async def handle_test(self, request):
return await self.render_template( return await whitelist_attributes(
"test.html", request, context={"name": "retoor"} self.render_template("test.html", request, context={"name": "retoor"})
) )
async def handle_http_get(self, request: web.Request): async def handle_http_get(self, request: web.Request):
@ -279,7 +382,6 @@ class Application(BaseApplication):
async for subscribed_channel in self.services.channel_member.find( async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
): ):
parent_object = await subscribed_channel.get_channel() parent_object = await subscribed_channel.get_channel()
item = {} item = {}
@ -323,15 +425,21 @@ class Application(BaseApplication):
request.session.get("uid") request.session.get("uid")
) )
try:
context["nonce"] = request['csp_nonce']
except:
context['nonce'] = '?'
rendered = await super().render_template(template, request, context) rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader self.jinja2_env.loader = self.original_loader
# rendered.text = whitelist_attributes(rendered.text)
# rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered return rendered
async def static_handler(self, request): async def static_handler(self, request):
file_name = request.match_info.get('filename', '') file_name = request.match_info.get("filename", "")
paths = [] paths = []
@ -365,16 +473,39 @@ class Application(BaseApplication):
if user_template_path: if user_template_path:
template_paths.append(user_template_path) template_paths.append(user_template_path)
template_paths.append(self.template_path) template_paths.append(self.template_path)
return FileSystemLoader(template_paths) return FileSystemLoader(template_paths)
@asynccontextmanager
async def no_save(self):
stats = {
'count': 0
}
async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0])
stats['count'] = stats['count'] + 1
print(f"save is ignored {stats['count']} times")
return args[0]
save_original = self.services.channel_message.mapper.save
self.services.channel_message.mapper.save = patched_save
raised_exception = None
try:
yield
except Exception as ex:
raised_exception = ex
finally:
self.services.channel_message.mapper.save = save_original
if raised_exception:
raise raised_exception
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")
#aiohttp_debugtoolbar.setup(app)
async def main(): 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__": if __name__ == "__main__":

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import sys import sys
class LoadBalancer: class LoadBalancer:
def __init__(self, backend_ports): def __init__(self, backend_ports):
self.backend_ports = backend_ports self.backend_ports = backend_ports
@ -8,27 +9,29 @@ class LoadBalancer:
self.client_counts = [0] * len(backend_ports) self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
async def start_backend_servers(self,port,workers): async def start_backend_servers(self, port, workers):
for x in range(workers): for x in range(workers):
port += 1 port += 1
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
sys.executable, sys.executable,
sys.argv[0], sys.argv[0],
'backend', "backend",
str(port), str(port),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
port += 1 port += 1
self.backend_processes.append(process) self.backend_processes.append(process)
print(f"Started backend server on port {(port-1)/port} with PID {process.pid}") print(
f"Started backend server on port {(port-1)/port} with PID {process.pid}"
)
async def handle_client(self, reader, writer): async def handle_client(self, reader, writer):
async with self.lock: async with self.lock:
min_clients = min(self.client_counts) min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients) server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1 self.client_counts[server_index] += 1
backend = ('127.0.0.1', self.backend_ports[server_index]) backend = ("127.0.0.1", self.backend_ports[server_index])
try: try:
backend_reader, backend_writer = await asyncio.open_connection(*backend) backend_reader, backend_writer = await asyncio.open_connection(*backend)
@ -62,10 +65,10 @@ class LoadBalancer:
for i, count in enumerate(self.client_counts): for i, count in enumerate(self.client_counts):
print(f"Server {self.backend_ports[i]}: {count} clients") print(f"Server {self.backend_ports[i]}: {count} clients")
async def start(self, host='0.0.0.0', port=8081,workers=5): async def start(self, host="0.0.0.0", port=8081, workers=5):
await self.start_backend_servers(port,workers) await self.start_backend_servers(port, workers)
server = await asyncio.start_server(self.handle_client, host, port) server = await asyncio.start_server(self.handle_client, host, port)
monitor_task = asyncio.create_task(self.monitor()) asyncio.create_task(self.monitor())
# Handle shutdown gracefully # Handle shutdown gracefully
try: try:
@ -80,6 +83,7 @@ class LoadBalancer:
await asyncio.gather(*(p.wait() for p in self.backend_processes)) await asyncio.gather(*(p.wait() for p in self.backend_processes))
print("Backend processes terminated.") print("Backend processes terminated.")
async def backend_echo_server(port): async def backend_echo_server(port):
async def handle_echo(reader, writer): async def handle_echo(reader, writer):
try: try:
@ -94,10 +98,11 @@ async def backend_echo_server(port):
finally: finally:
writer.close() writer.close()
server = await asyncio.start_server(handle_echo, '127.0.0.1', port) server = await asyncio.start_server(handle_echo, "127.0.0.1", port)
print(f"Backend echo server running on port {port}") print(f"Backend echo server running on port {port}")
await server.serve_forever() await server.serve_forever()
async def main(): async def main():
backend_ports = [8001, 8003, 8005, 8006] backend_ports = [8001, 8003, 8005, 8006]
# Launch backend echo servers # Launch backend echo servers
@ -105,19 +110,20 @@ async def main():
lb = LoadBalancer(backend_ports) lb = LoadBalancer(backend_ports)
await lb.start() await lb.start()
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1: if len(sys.argv) > 1:
if sys.argv[1] == 'backend': if sys.argv[1] == "backend":
port = int(sys.argv[2]) port = int(sys.argv[2])
from snek.app import Application from snek.app import Application
snek = Application(port=port) snek = Application(port=port)
web.run_app(snek, port=port, host='127.0.0.1') web.run_app(snek, port=port, host="127.0.0.1")
elif sys.argv[1] == 'sync': elif sys.argv[1] == "sync":
from snek.sync import app
web.run_app(snek, port=port, host='127.0.0.1') web.run_app(snek, port=port, host="127.0.0.1")
else: else:
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
print("Shutting down...") print("Shutting down...")

115
src/snek/forum.py Normal file
View File

@ -0,0 +1,115 @@
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
class ForumApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.render_template = self.parent.render_template
# Set up routes
self.setup_routes()
# Set up notification listeners
self.setup_notifications()
@property
def db(self):
return self.parent.db
@property
def services(self):
return self.parent.services
def setup_routes(self):
"""Set up all forum routes"""
# API routes
self.router.add_view("/index.html", ForumIndexView)
self.router.add_route("GET", "/api/forums", ForumView.get_forums)
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread)
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post)
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like)
self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin)
self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock)
# WebSocket route
self.router.add_view("/ws", ForumWebSocketView)
# Static HTML route
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
def setup_notifications(self):
"""Set up notification listeners for WebSocket broadcasting"""
# Forum notifications
self.services.forum.add_notification_listener("forum_created", self.on_forum_event)
# Thread notifications
self.services.thread.add_notification_listener("thread_created", self.on_thread_event)
# Post notifications
self.services.post.add_notification_listener("post_created", self.on_post_event)
self.services.post.add_notification_listener("post_edited", self.on_post_event)
self.services.post.add_notification_listener("post_deleted", self.on_post_event)
# Like notifications
self.services.post_like.add_notification_listener("post_liked", self.on_like_event)
self.services.post_like.add_notification_listener("post_unliked", self.on_like_event)
async def on_forum_event(self, event_type, data):
"""Handle forum events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_thread_event(self, event_type, data):
"""Handle thread events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_post_event(self, event_type, data):
"""Handle post events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_like_event(self, event_type, data):
"""Handle like events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app
def setup_forum(app):
"""Set up forum sub-application"""
forum_app = ForumApplication(app)
app.add_subapp("/forum", forum_app)
app.forum_app = forum_app
# Register models and services if needed
# This would typically be done in your main app initialization
return forum_app

View File

@ -1,21 +1,22 @@
import functools import functools
from snek.mapper.channel import ChannelMapper from snek.mapper.channel import ChannelMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.channel_member import ChannelMemberMapper from snek.mapper.channel_member import ChannelMemberMapper
from snek.mapper.channel_message import ChannelMessageMapper from snek.mapper.channel_message import ChannelMessageMapper
from snek.mapper.container import ContainerMapper
from snek.mapper.drive import DriveMapper from snek.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper from snek.mapper.drive_item import DriveItemMapper
from snek.mapper.notification import NotificationMapper 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 import UserMapper
from snek.mapper.user_property import UserPropertyMapper from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.repository import RepositoryMapper from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.container import ContainerMapper
from snek.system.object import Object from snek.system.object import Object
@functools.cache @functools.cache
def get_mappers(app=None): def get_mappers(app=None):
return Object( return Object(
**{ **{
@ -30,9 +31,15 @@ def get_mappers(app=None):
"repository": RepositoryMapper(app=app), "repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app), "channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app), "container": ContainerMapper(app=app),
"push": PushMapper(app=app),
"forum": ForumMapper(app=app),
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
} }
) )
def get_mapper(name, app=None): def get_mapper(name, app=None):
return get_mappers(app=app)[name] return get_mappers(app=app)[name]

View File

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

23
src/snek/mapper/forum.py Normal file
View File

@ -0,0 +1,23 @@
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper
class ForumMapper(BaseMapper):
table_name = "forum"
model_class = ForumModel
class ThreadMapper(BaseMapper):
table_name = "thread"
model_class = ThreadModel
class PostMapper(BaseMapper):
table_name = "post"
model_class = PostModel
class PostLikeMapper(BaseMapper):
table_name = "post_like"
model_class = PostLikeModel

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

@ -1,18 +1,20 @@
import functools import functools
from snek.model.channel import ChannelModel from snek.model.channel import ChannelModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.channel_member import ChannelMemberModel from snek.model.channel_member import ChannelMemberModel
# from snek.model.channel_message import ChannelMessageModel # from snek.model.channel_message import ChannelMessageModel
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 import DriveModel
from snek.model.drive_item import DriveItemModel from snek.model.drive_item import DriveItemModel
from snek.model.notification import NotificationModel 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 import UserModel
from snek.model.user_property import UserPropertyModel from snek.model.user_property import UserPropertyModel
from snek.model.repository import RepositoryModel from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.container import Container
from snek.system.object import Object from snek.system.object import Object
@ -31,9 +33,16 @@ def get_models():
"repository": RepositoryModel, "repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel, "channel_attachment": ChannelAttachmentModel,
"container": Container, "container": Container,
"push_registration": PushRegistrationModel,
"forum": ForumModel,
"thread": ThreadModel,
"post": PostModel,
"post_like": PostLikeModel,
} }
) )
def get_model(name): def get_model(name):
return get_models()[name] return get_models()[name]

View File

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

View File

@ -1,4 +1,3 @@
from snek.system.model import BaseModel
from snek.system.model import BaseModel, ModelField from snek.system.model import BaseModel, ModelField
@ -11,6 +10,6 @@ class ChannelAttachmentModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str) user_uid = ModelField(name="user_uid", required=True, kind=str)
mime_type = ModelField(name="type", required=True, kind=str) mime_type = ModelField(name="type", required=True, kind=str)
relative_url = ModelField(name="relative_url", 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") resource_type = ModelField(
name="resource_type", required=True, kind=str, value="file"
)

View File

@ -1,6 +1,8 @@
from datetime import datetime, timezone
from snek.model.user import UserModel from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField from snek.system.model import BaseModel, ModelField
from datetime import datetime,timezone
class ChannelMessageModel(BaseModel): class ChannelMessageModel(BaseModel):
channel_uid = ModelField(name="channel_uid", required=True, kind=str) channel_uid = ModelField(name="channel_uid", required=True, kind=str)
@ -10,7 +12,11 @@ class ChannelMessageModel(BaseModel):
is_final = ModelField(name="is_final", required=True, kind=bool, value=True) is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self): def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds()) return int(
(
datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])
).total_seconds()
)
async def get_user(self) -> UserModel: async def get_user(self) -> UserModel:
return await self.app.services.user.get(uid=self["user_uid"]) return await self.app.services.user.get(uid=self["user_uid"])

View File

@ -1,5 +1,6 @@
from snek.system.model import BaseModel, ModelField from snek.system.model import BaseModel, ModelField
class Container(BaseModel): class Container(BaseModel):
id = ModelField(name="id", required=True, kind=str) id = ModelField(name="id", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str) name = ModelField(name="name", required=True, kind=str)

View File

@ -9,7 +9,9 @@ class DriveItemModel(BaseModel):
path = ModelField(name="path", required=True, kind=str) path = ModelField(name="path", required=True, kind=str)
file_type = ModelField(name="file_type", required=True, kind=str) file_type = ModelField(name="file_type", required=True, kind=str)
file_size = ModelField(name="file_size", required=True, kind=int) file_size = ModelField(name="file_size", required=True, kind=int)
is_available = ModelField(name="is_available", required=True, kind=bool, initial_value=True) is_available = ModelField(
name="is_available", required=True, kind=bool, initial_value=True
)
@property @property
def extension(self): def extension(self):

99
src/snek/model/forum.py Normal file
View File

@ -0,0 +1,99 @@
# models/forum.py
from snek.system.model import BaseModel, ModelField
class ForumModel(BaseModel):
"""Forum categories"""
name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
description = ModelField(name="description", required=False, kind=str, max_length=500)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True)
icon = ModelField(name="icon", required=False, kind=str)
position = ModelField(name="position", required=True, kind=int, value=0)
is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
async def get_threads(self, limit=50, offset=0):
async for thread in self.app.services.thread.find(
forum_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
order_by="-last_post_at"
#order_by="is_pinned DESC, last_post_at DESC"
):
yield thread
async def increment_thread_count(self):
self["thread_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
await self.save()
# models/thread.py
class ThreadModel(BaseModel):
"""Forum threads"""
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
view_count = ModelField(name="view_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
async def get_posts(self, limit=50, offset=0):
async for post in self.app.services.post.find(
thread_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
order_by="created_at"
):
yield post
async def increment_view_count(self):
self["view_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
self["last_post_at"] = self.app.services.get_timestamp()
await self.save()
# models/post.py
class PostModel(BaseModel):
"""Forum posts"""
thread_uid = ModelField(name="thread_uid", required=True, kind=str)
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
edited_at = ModelField(name="edited_at", required=False, kind=str)
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False)
like_count = ModelField(name="like_count", required=True, kind=int, value=0)
async def get_author(self):
return await self.app.services.user.get(uid=self["created_by_uid"])
async def is_liked_by(self, user_uid):
return await self.app.services.post_like.exists(
post_uid=self["uid"],
user_uid=user_uid
)
# models/post_like.py
class PostLikeModel(BaseModel):
"""Post likes"""
post_uid = ModelField(name="post_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)

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

@ -1,4 +1,3 @@
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField from snek.system.model import BaseModel, ModelField
@ -9,6 +8,3 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str) name = ModelField(name="name", required=True, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool) is_private = ModelField(name="is_private", required=False, kind=bool)

View File

@ -31,6 +31,14 @@ class UserModel(BaseModel):
is_admin = ModelField(name="is_admin", required=False, kind=bool) 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): async def get_property(self, name):
prop = await self.app.services.user_property.find_one( prop = await self.app.services.user_property.find_one(
user_uid=self["uid"], name=name user_uid=self["uid"], name=name

View File

@ -1,51 +1,54 @@
import snek.serpentarium
import time import time
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
import snek.serpentarium
durations = [] durations = []
def task1(): def task1():
global durations global durations
client = snek.serpentarium.DatasetWrapper() client = snek.serpentarium.DatasetWrapper()
start=time.time() start = time.time()
for x in range(1500): for x in range(1500):
client['a'].delete() client["a"].delete()
client['a'].insert({"foo": x}) client["a"].insert({"foo": x})
client['a'].find(foo=x) client["a"].find(foo=x)
client['a'].find_one(foo=x) client["a"].find_one(foo=x)
client['a'].count() client["a"].count()
#print(client['a'].find(foo=x) ) # print(client['a'].find(foo=x) )
#print(client['a'].find_one(foo=x) ) # print(client['a'].find_one(foo=x) )
#print(client['a'].count()) # print(client['a'].count())
client.close() client.close()
duration1 = f"{time.time()-start}" duration1 = f"{time.time()-start}"
durations.append(duration1) durations.append(duration1)
print(durations) print(durations)
with ProcessPoolExecutor(max_workers=4) as executor: with ProcessPoolExecutor(max_workers=4) as executor:
tasks = [executor.submit(task1), tasks = [
executor.submit(task1),
executor.submit(task1),
executor.submit(task1), executor.submit(task1),
executor.submit(task1), executor.submit(task1),
executor.submit(task1)
] ]
for task in tasks: for task in tasks:
task.result() task.result()
import dataset import dataset
client = dataset.connect("sqlite:///snek.db") client = dataset.connect("sqlite:///snek.db")
start=time.time() start = time.time()
for x in range(1500): for x in range(1500):
client['a'].delete() client["a"].delete()
client['a'].insert({"foo": x}) client["a"].insert({"foo": x})
print([dict(row) for row in client['a'].find(foo=x)]) print([dict(row) for row in client["a"].find(foo=x)])
print(dict(client['a'].find_one(foo=x) )) print(dict(client["a"].find_one(foo=x)))
print(client['a'].count()) print(client["a"].count())
duration2 = f"{time.time()-start}" duration2 = f"{time.time()-start}"
print(duration1,duration2) print(duration1, duration2)

View File

@ -1,44 +1,65 @@
import functools import functools
from snek.service.channel import ChannelService from snek.service.channel import ChannelService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.channel_member import ChannelMemberService from snek.service.channel_member import ChannelMemberService
from snek.service.channel_message import ChannelMessageService from snek.service.channel_message import ChannelMessageService
from snek.service.chat import ChatService 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 import DriveService
from snek.service.drive_item import DriveItemService from snek.service.drive_item import DriveItemService
from snek.service.notification import NotificationService 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.socket import SocketService
from snek.service.user import UserService from snek.service.user import UserService
from snek.service.user_property import UserPropertyService from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService from snek.service.util import UtilService
from snek.service.repository import RepositoryService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.container import ContainerService
from snek.system.object import Object from snek.system.object import Object
from snek.service.db import DBService from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
_service_registry = {}
def register_service(name, service_cls):
_service_registry[name] = service_cls
register = register_service
@functools.cache @functools.cache
def get_services(app): def get_services(app):
return Object( result = Object(
**{ **{
"user": UserService(app=app), name: service_cls(app=app)
"channel_member": ChannelMemberService(app=app), for name, service_cls in _service_registry.items()
"channel": ChannelService(app=app),
"channel_message": ChannelMessageService(app=app),
"chat": ChatService(app=app),
"socket": SocketService(app=app),
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app),
"user_property": UserPropertyService(app=app),
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
} }
) )
result.register = register_service
return result
def get_service(name, app=None): def get_service(name, app=None):
return get_services(app=app)[name] return get_services(app=app)[name]
# Registering all services
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
register_service("channel_message", ChannelMessageService)
register_service("chat", ChatService)
register_service("socket", SocketService)
register_service("notification", NotificationService)
register_service("util", UtilService)
register_service("drive", DriveService)
register_service("drive_item", DriveItemService)
register_service("user_property", UserPropertyService)
register_service("repository", RepositoryService)
register_service("db", DBService)
register_service("channel_attachment", ChannelAttachmentService)
register_service("container", ContainerService)
register_service("push", PushService)
register_service("statistics", StatisticsService)
register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)

View File

@ -1,19 +1,26 @@
import pathlib
from datetime import datetime from datetime import datetime
from snek.system.model import now from snek.system.model import now
from snek.system.service import BaseService from snek.system.service import BaseService
import pathlib
class ChannelService(BaseService): class ChannelService(BaseService):
mapper_name = "channel" mapper_name = "channel"
async def get_attachment_folder(self, channel_uid,ensure=False): 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") path = pathlib.Path(f"./drive/{channel_uid}/attachments")
if ensure: if ensure:
path.mkdir( path.mkdir(parents=True, exist_ok=True)
parents=True, exist_ok=True
)
return path return path
async def get(self, uid=None, **kwargs): async def get(self, uid=None, **kwargs):
@ -56,6 +63,7 @@ class ChannelService(BaseService):
model["is_private"] = is_private model["is_private"] = is_private
model["is_listed"] = is_listed model["is_listed"] = is_listed
if await self.save(model): if await self.save(model):
await self.services.container.create(model["uid"])
return model return model
raise Exception(f"Failed to create channel: {model.errors}.") raise Exception(f"Failed to create channel: {model.errors}.")
@ -68,7 +76,10 @@ class ChannelService(BaseService):
return channel return channel
async def get_recent_users(self, channel_uid): 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}): 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 yield user
async def get_users(self, channel_uid): async def get_users(self, channel_uid):
@ -102,6 +113,12 @@ class ChannelService(BaseService):
channel = await self.get(uid=channel_member["channel_uid"]) channel = await self.get(uid=channel_member["channel_uid"])
yield channel yield channel
async def clear(self, channel_uid):
model = await self.get(uid=channel_uid)
model['history_from'] = datetime.now()
await self.save(model)
async def ensure_public_channel(self, created_by_uid): async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public") model = await self.get(is_listed=True, tag="public")
is_moderator = False is_moderator = False

View File

@ -1,25 +1,25 @@
from snek.system.service import BaseService
import urllib.parse
import pathlib
import mimetypes import mimetypes
import uuid
from snek.system.service import BaseService
class ChannelAttachmentService(BaseService): class ChannelAttachmentService(BaseService):
mapper_name="channel_attachment" mapper_name = "channel_attachment"
async def create_file(self, channel_uid, user_uid, name): async def create_file(self, channel_uid, user_uid, name):
attachment = await self.new() attachment = await self.new()
attachment["channel_uid"] = channel_uid attachment["channel_uid"] = channel_uid
attachment['user_uid'] = user_uid attachment["user_uid"] = user_uid
attachment["name"] = name attachment["name"] = name
attachment["mime_type"] = mimetypes.guess_type(name)[0] attachment["mime_type"] = mimetypes.guess_type(name)[0]
attachment['resource_type'] = "file" attachment["resource_type"] = "file"
real_file_name = f"{attachment['uid']}-{name}" real_file_name = f"{attachment['uid']}-{name}"
attachment["relative_url"] = (f"{attachment['uid']}-{name}") attachment["relative_url"] = f"{attachment['uid']}-{name}"
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid) attachment_folder = await self.services.channel.get_attachment_folder(
channel_uid
)
attachment_path = attachment_folder.joinpath(real_file_name) attachment_path = attachment_folder.joinpath(real_file_name)
attachment["path"] = str(attachment_path) attachment["path"] = str(attachment_path)
if await self.save(attachment): if await self.save(attachment):
return attachment return attachment
raise Exception(f"Failed to create channel attachment: {attachment.errors}.") raise Exception(f"Failed to create channel attachment: {attachment.errors}.")

View File

@ -1,16 +1,88 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import sanitize_html
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader
global jinja2_env
import pathlib
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template =jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
mapper_name = "channel_message" mapper_name = "channel_message"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._configured_indexes = False
self._executor_pools = {}
global jinja2_env
jinja2_env = self.app.jinja2_env
self._max_workers = 1
def get_or_create_executor(self, uid):
if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=5)
return self._executor_pools[uid]
def delete_executor(self, uid):
if uid in self._executor_pools:
self._executor_pools[uid].shutdown()
del self._executor_pools[uid]
async def maintenance(self):
args = {}
for message in self.mapper.db["channel_message"].find():
print(message)
try:
message = await self.get(uid=message["uid"])
updated_at = message["updated_at"]
message["is_final"] = True
html = message["html"]
await self.save(message)
self.mapper.db["channel_message"].upsert(
{
"uid": message["uid"],
"updated_at": updated_at,
},
["uid"],
)
if html != message["html"]:
print("Reredefined message", message["uid"])
except Exception as ex:
time.sleep(0.1)
print(ex, flush=True)
while True:
changed = 0
async for message in self.find(is_final=False):
message["is_final"] = True
await self.save(message)
changed += 1
async for message in self.find(is_final=None):
message["is_final"] = False
await self.save(message)
changed += 1
if not changed:
break
async def create(self, channel_uid, user_uid, message, is_final=True): async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new() model = await self.new()
model["channel_uid"] = channel_uid model["channel_uid"] = channel_uid
model["user_uid"] = user_uid model["user_uid"] = user_uid
model["message"] = message model["message"] = message
model['is_final'] = is_final model["is_final"] = is_final
context = {} context = {}
@ -25,13 +97,34 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
loop = asyncio.get_event_loop()
try: try:
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) context = json.loads(json.dumps(context, default=str))
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
if await super().save(model): if await super().save(model):
if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index(
["is_final", "user_uid", "channel_uid"]
):
self.mapper.db["channel_message"].create_index(
["is_final", "user_uid", "channel_uid"], unique=False
)
if not self.mapper.db["channel_message"].has_index(["uid"]):
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
self.mapper.db["channel_message"].create_index(
["deleted_at"], unique=False
)
self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
return model return model
raise Exception(f"Failed to create channel message: {model.errors}.") raise Exception(f"Failed to create channel message: {model.errors}.")
@ -39,6 +132,11 @@ class ChannelMessageService(BaseService):
user = await self.services.user.get(uid=message["user_uid"]) user = await self.services.user.get(uid=message["user_uid"])
if not user: if not user:
return {} return {}
#if not message["html"].startswith("<chat-message"):
#message = await self.get(uid=message["uid"])
#await self.save(message)
return { return {
"uid": message["uid"], "uid": message["uid"],
"color": user["color"], "color": user["color"],
@ -54,7 +152,7 @@ class ChannelMessageService(BaseService):
async def save(self, model): async def save(self, model):
context = {} context = {}
context.update(model.record) context.update(model.record)
user = await self.app.services.user.get(model['user_uid']) user = await self.app.services.user.get(model["user_uid"])
context.update( context.update(
{ {
"user_uid": user["uid"], "user_uid": user["uid"],
@ -63,17 +161,29 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
template = self.app.jinja2_env.get_template("message.html") context = json.loads(json.dumps(context, default=str))
model["html"] = template.render(**context) loop = asyncio.get_event_loop()
return await super().save(model) model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
result = await super().save(model)
if model['is_final']:
self.delete_executor(model['uid'])
return result
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return []
history_start_filter = ""
if channel["history_start"]:
history_start_filter = f" AND created_at > '{channel['history_start']}'"
results = [] results = []
offset = page * page_size offset = page * page_size
try: try:
if timestamp: if timestamp:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -84,7 +194,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
elif page > 0: elif page > 0:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size", f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -95,7 +205,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
else: else:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{ {
"channel_uid": channel_uid, "channel_uid": channel_uid,
"page_size": page_size, "page_size": page_size,
@ -104,7 +214,7 @@ class ChannelMessageService(BaseService):
): ):
results.append(model) results.append(model)
except: except Exception as ex:
pass print(ex)
results.sort(key=lambda x: x["created_at"]) results.sort(key=lambda x: x["created_at"])
return results return results

View File

@ -13,13 +13,13 @@ class ChatService(BaseService):
channel["last_message_on"] = now() channel["last_message_on"] = now()
await self.services.channel.save(channel) await self.services.channel.save(channel)
await self.services.socket.broadcast( await self.services.socket.broadcast(
channel['uid'], channel["uid"],
{ {
"message": channel_message["message"], "message": channel_message["message"],
"html": channel_message["html"], "html": channel_message["html"],
"user_uid": user['uid'], "user_uid": user["uid"],
"color": user["color"], "color": user["color"],
"channel_uid": channel['uid'], "channel_uid": channel["uid"],
"created_at": channel_message["created_at"], "created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"], "updated_at": channel_message["updated_at"],
"username": user["username"], "username": user["username"],
@ -28,14 +28,28 @@ class ChatService(BaseService):
"is_final": channel_message["is_final"], "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): async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
raise Exception("Channel not found.") raise Exception("Channel not found.")
channel_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False
)
if channel_message:
channel_message["message"] = message
channel_message["is_final"] = is_final
if not channel_message["is_final"]:
async with self.app.no_save():
await self.services.channel_message.save(channel_message)
else:
await self.services.channel_message.save(channel_message)
else:
channel_message = await self.services.channel_message.create( channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message,is_final channel_uid, user_uid, message, is_final
) )
channel_message_uid = channel_message["uid"] channel_message_uid = channel_message["uid"]

View File

@ -1,9 +1,107 @@
from snek.system.docker import ComposeFileManager
from snek.system.service import BaseService from snek.system.service import BaseService
class ContainerService(BaseService): class ContainerService(BaseService):
mapper_name = "container" mapper_name = "container"
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.compose_path = "snek-container-compose.yml"
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
self.event_listeners = {}
async def shutdown(self):
return await self.compose.shutdown()
async def add_event_listener(self, name, event,event_handler):
if not name in self.event_listeners:
self.event_listeners[name] = {}
if not event in self.event_listeners[name]:
self.event_listeners[name][event] = []
self.event_listeners[name][event].append(event_handler)
async def remove_event_listener(self, name, event, event_handler):
if name in self.event_listeners and event in self.event_listeners[name]:
try:
self.event_listeners[name][event].remove(event_handler)
except ValueError:
pass
async def container_event_handler(self, name, event, data):
event_listeners = self.event_listeners.get(name, {})
handlers = event_listeners.get(event, [])
for handler in handlers:
if not await handler(data):
handlers.remove(handler)
async def get_instances(self):
return list(self.compose.list_instances())
async def get_container_name(self, channel_uid):
if channel_uid.startswith("channel-"):
return channel_uid
return f"channel-{channel_uid}"
async def get(self,channel_uid):
return await self.compose.get_instance(await self.get_container_name(channel_uid))
async def stop(self, channel_uid):
return await self.compose.stop(await self.get_container_name(channel_uid))
async def start(self, channel_uid):
return await self.compose.start(await self.get_container_name(channel_uid))
async def maintenance(self):
async for channel in self.services.channel.find():
if not await self.get(channel["uid"]):
print("Creating container for channel", channel["uid"])
result = await self.create(channel_uid=channel["uid"])
print(result)
async def get_status(self, channel_uid):
return await self.compose.get_instance_status(await self.get_container_name(channel_uid))
async def write_stdin(self, channel_uid, data):
return await self.compose.write_stdin(await self.get_container_name(channel_uid), data)
async def create(
self,
channel_uid,
image="ubuntu:latest",
command=None,
cpus=1,
memory="1024m",
ports=None,
volumes=None,
):
name = await self.get_container_name(channel_uid)
test = await self.compose.get_instance(name)
if test:
return test
self.compose.create_instance(
name,
image,
command,
cpus,
memory,
ports,
[
"./"
+ str(await self.services.channel.get_home_folder(channel_uid))
+ ":"
+ "/root"
],
)
return await self.compose.get_instance(name)
async def create2(
self, id, name, status, resources=None, user_uid=None, path=None, readonly=False
):
model = await self.new() model = await self.new()
model["id"] = id model["id"] = id
model["name"] = name model["name"] = name
@ -19,11 +117,3 @@ class ContainerService(BaseService):
return model return model
raise Exception(f"Failed to create container: {model.errors}") raise Exception(f"Failed to create container: {model.errors}")
async def get(self, id):
return await self.mapper.get(id)
async def update(self, model):
return await self.mapper.update(model)
async def delete(self, id):
return await self.mapper.delete(id)

View File

@ -1,8 +1,7 @@
from snek.system.service import BaseService
import dataset import dataset
import uuid
from datetime import datetime from snek.system.service import BaseService
class DBService(BaseService): class DBService(BaseService):
@ -18,7 +17,6 @@ class DBService(BaseService):
db = await self.get_db(user_uid) db = await self.get_db(user_uid)
return db[table_name].insert(values) return db[table_name].insert(values)
async def update(self, user_uid, table_name, values, filters): async def update(self, user_uid, table_name, values, filters):
db = await self.get_db(user_uid) db = await self.get_db(user_uid)
if not filters: if not filters:
@ -33,7 +31,7 @@ class DBService(BaseService):
async def find(self, user_uid, table_name, kwargs): async def find(self, user_uid, table_name, kwargs):
db = await self.get_db(user_uid) db = await self.get_db(user_uid)
kwargs['_limit'] = kwargs.get('_limit', 30) kwargs["_limit"] = kwargs.get("_limit", 30)
return [dict(row) for row in db[table_name].find(**kwargs)] return [dict(row) for row in db[table_name].find(**kwargs)]
async def get(self, user_uid, table_name, filters): async def get(self, user_uid, table_name, filters):
@ -45,14 +43,13 @@ class DBService(BaseService):
except ValueError: except ValueError:
return None return None
async def delete(self, user_uid, table_name, filters): async def delete(self, user_uid, table_name, filters):
db = await self.get_db(user_uid) db = await self.get_db(user_uid)
if not filters: if not filters:
filters = {} filters = {}
return db[table_name].delete(**filters) return db[table_name].delete(**filters)
async def query(self, sql,values): async def query(self, sql, values):
db = await self.app.db db = await self.app.db
return [dict(row) for row in db.query(sql, values or {})] return [dict(row) for row in db.query(sql, values or {})]
@ -62,8 +59,6 @@ class DBService(BaseService):
filters = {} filters = {}
return bool(db[table_name].find_one(**filters)) return bool(db[table_name].find_one(**filters))
async def count(self, user_uid, table_name, filters): async def count(self, user_uid, table_name, filters):
db = await self.get_db(user_uid) db = await self.get_db(user_uid)
if not filters: if not filters:

304
src/snek/service/forum.py Normal file
View File

@ -0,0 +1,304 @@
# services/forum.py
from snek.system.service import BaseService
import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
"""
Base mix-in that gives a service `add_notification_listener`,
an internal `_dispatch_event` helper, and a public `notify` method.
"""
def get_timestamp(self):
return now()
def generate_uid(self):
return str(uuid.uuid4())
def __init__(self, *args, **kwargs) -> None:
# Map event name -> list of listener callables
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
super().__init__(*args, **kwargs)
def add_notification_listener(
self, event_name: str, listener: EventListener
) -> None:
"""
Register a callback to be fired when `event_name` happens.
Parameters
----------
event_name : str
The name of the domain event, e.g. "post_created".
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
Your handler; can be async or sync.
"""
if not callable(listener):
raise TypeError("listener must be callable")
self._listeners[event_name].append(listener)
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if hasattr(listener, "__await__"): # async function or coro
await listener(event_name, data)
else: # plain sync function
listener(event_name, data)
async def notify(self, event_name: str, data: Any) -> None:
"""
Public method to trigger notification to all listeners of an event.
Parameters
----------
event_name : str
The name of the event to notify listeners about.
data : Any
The data to pass to the listeners.
"""
await self._dispatch_event(event_name, data)
class ForumService(BaseForumService):
mapper_name = "forum"
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
if not slug:
slug = self.generate_slug(name)
# Check if slug exists
existing = await self.get(slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
model = await self.new()
model["name"] = name
model["description"] = description
model["slug"] = slug
model["created_by_uid"] = created_by_uid
if icon:
model["icon"] = icon
if await self.save(model):
await self.notify("forum_created", model)
return model
raise Exception(f"Failed to create forum: {model.errors}")
def generate_slug(self, text):
# Convert to lowercase and replace spaces with hyphens
slug = text.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug
async def get_active_forums(self):
async for forum in self.find(is_active=True, order_by="position"):
yield forum
async def update_last_post(self, forum_uid, thread_uid):
forum = await self.get(uid=forum_uid)
if forum:
forum["last_post_at"] = self.get_timestamp()
forum["last_thread_uid"] = thread_uid
await self.save(forum)
# services/thread.py
class ThreadService(BaseForumService):
mapper_name = "thread"
async def create_thread(self, forum_uid, title, content, created_by_uid):
# Generate slug
slug = self.services.forum.generate_slug(title)
# Check if slug exists in this forum
existing = await self.get(forum_uid=forum_uid, slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
# Create thread
thread = await self.new()
thread["forum_uid"] = forum_uid
thread["title"] = title
thread["slug"] = slug
thread["created_by_uid"] = created_by_uid
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
if await self.save(thread):
# Create first post
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=forum_uid,
content=content,
created_by_uid=created_by_uid,
is_first_post=True
)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_thread_count()
await self.services.forum.update_last_post(forum_uid, thread["uid"])
await self.notify("thread_created", {
"thread": thread,
"forum_uid": forum_uid
})
return thread, post
raise Exception(f"Failed to create thread: {thread.errors}")
async def toggle_pin(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"):
return None
thread["is_pinned"] = not thread["is_pinned"]
await self.save(thread)
return thread
async def toggle_lock(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin or thread creator
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
return None
thread["is_locked"] = not thread["is_locked"]
await self.save(thread)
return thread
# services/post.py
class PostService(BaseForumService):
mapper_name = "post"
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
# Check if thread is locked
thread = await self.services.thread.get(uid=thread_uid)
if thread["is_locked"] and not is_first_post:
raise Exception("Thread is locked")
post = await self.new()
post["thread_uid"] = thread_uid
post["forum_uid"] = forum_uid
post["content"] = content
post["created_by_uid"] = created_by_uid
post["is_first_post"] = is_first_post
if await self.save(post):
# Update thread counters
if not is_first_post:
thread["post_count"] += 1
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
await self.services.thread.save(thread)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_post_count()
await self.services.forum.update_last_post(forum_uid, thread_uid)
await self.notify("post_created", {
"post": post,
"thread_uid": thread_uid,
"forum_uid": forum_uid
})
return post
raise Exception(f"Failed to create post: {post.errors}")
async def edit_post(self, post_uid, content, user_uid):
post = await self.get(uid=post_uid)
if not post:
return None
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return None
post["content"] = content
post["edited_at"] = self.get_timestamp()
post["edited_by_uid"] = user_uid
if await self.save(post):
await self.notify("post_edited", post)
return post
return None
async def delete_post(self, post_uid, user_uid):
post = await self.get(uid=post_uid)
if not post:
return False
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return False
# Don't allow deleting first post
if post["is_first_post"]:
return False
post["deleted_at"] = self.get_timestamp()
if await self.save(post):
await self.notify("post_deleted", post)
return True
return False
# services/post_like.py
class PostLikeService(BaseForumService):
mapper_name = "post_like"
async def toggle_like(self, post_uid, user_uid):
# Check if already liked
existing = await self.get(post_uid=post_uid, user_uid=user_uid)
if existing:
# Unlike
await self.delete(uid=existing["uid"])
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] = max(0, post["like_count"] - 1)
await self.services.post.save(post)
await self.notify("post_unliked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return False
else:
# Like
like = await self.new()
like["post_uid"] = post_uid
like["user_uid"] = user_uid
if await self.save(like):
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] += 1
await self.services.post.save(post)
await self.notify("post_liked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return True
return None

View File

@ -1,3 +1,4 @@
from snek.system.markdown import strip_markdown
from snek.system.model import now from snek.system.model import now
from snek.system.service import BaseService from snek.system.service import BaseService
@ -33,6 +34,8 @@ class NotificationService(BaseService):
channel_message = await self.services.channel_message.get( channel_message = await self.services.channel_message.get(
uid=channel_message_uid uid=channel_message_uid
) )
if not channel_message["is_final"]:
return
user = await self.services.user.get(uid=channel_message["user_uid"]) user = await self.services.user.get(uid=channel_message["user_uid"])
self.app.db.begin() self.app.db.begin()
async for channel_member in self.services.channel_member.find( async for channel_member in self.services.channel_member.find(
@ -62,4 +65,20 @@ class NotificationService(BaseService):
except Exception: except Exception:
raise Exception(f"Failed to create notification: {model.errors}.") 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() 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

@ -1,13 +1,17 @@
from snek.system.service import BaseService
import asyncio import asyncio
import shutil import shutil
from snek.system.service import BaseService
class RepositoryService(BaseService): class RepositoryService(BaseService):
mapper_name = "repository" mapper_name = "repository"
async def delete(self, user_uid, name): async def delete(self, user_uid, name):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name) repository_path = (
await self.services.user.get_repository_path(user_uid)
).joinpath(name)
try: try:
await loop.run_in_executor(None, shutil.rmtree, repository_path) await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex: except Exception as ex:
@ -15,7 +19,6 @@ class RepositoryService(BaseService):
await super().delete(user_uid=user_uid, name=name) await super().delete(user_uid=user_uid, name=name)
async def exists(self, user_uid, name, **kwargs): async def exists(self, user_uid, name, **kwargs):
kwargs["user_uid"] = user_uid kwargs["user_uid"] = user_uid
kwargs["name"] = name kwargs["name"] = name
@ -29,16 +32,14 @@ class RepositoryService(BaseService):
repository_path = str(repository_path) repository_path = str(repository_path)
if not repository_path.endswith(".git"): if not repository_path.endswith(".git"):
repository_path += ".git" repository_path += ".git"
command = ['git', 'init', '--bare', repository_path] command = ["git", "init", "--bare", repository_path]
process = await asyncio.subprocess.create_subprocess_exec( process = await asyncio.subprocess.create_subprocess_exec(
*command, *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return process.returncode == 0 return process.returncode == 0
async def create(self, user_uid, name,is_private=False): async def create(self, user_uid, name, is_private=False):
if await self.exists(user_uid=user_uid, name=name): if await self.exists(user_uid=user_uid, name=name):
return False return False

View File

@ -1,12 +1,14 @@
from snek.model.user import UserModel
from snek.system.service import BaseService
from datetime import datetime
import json
import asyncio import asyncio
import logging import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from snek.system.model import now from snek.system.model import now
class SocketService(BaseService): class SocketService(BaseService):
class Socket: class Socket:
@ -15,7 +17,6 @@ class SocketService(BaseService):
self.is_connected = True self.is_connected = True
self.user = user self.user = user
async def send_json(self, data): async def send_json(self, data):
if not self.is_connected: if not self.is_connected:
return False return False
@ -41,23 +42,33 @@ class SocketService(BaseService):
self.subscriptions = {} self.subscriptions = {}
self.last_update = str(datetime.now()) self.last_update = str(datetime.now())
async def user_availability_service(self): async def user_availability_service(self):
logger.info("User availability update service started.") logger.info("User availability update service started.")
logger.debug("Entering the main loop.")
while True: while True:
logger.info("Updating user availability...") logger.info("Updating user availability...")
logger.debug("Initializing users_updated list.")
users_updated = [] users_updated = []
logger.debug("Iterating over sockets.")
for s in self.sockets: for s in self.sockets:
logger.debug(f"Checking connection status for socket: {s}.")
if not s.is_connected: if not s.is_connected:
logger.debug("Socket is not connected, continuing to next socket.")
continue continue
if not s.user in users_updated: logger.debug(f"Checking if user {s.user} is already updated.")
if s.user not in users_updated:
logger.debug(f"Updating last_ping for user: {s.user}.")
s.user["last_ping"] = now() s.user["last_ping"] = now()
logger.debug(f"Saving user {s.user} to the database.")
await self.app.services.user.save(s.user) await self.app.services.user.save(s.user)
logger.debug(f"Adding user {s.user} to users_updated list.")
users_updated.append(s.user) users_updated.append(s.user)
logger.info(f"Updated user availability for {len(users_updated)} online users.") logger.info(
f"Updated user availability for {len(users_updated)} online users."
)
logger.debug("Sleeping for 60 seconds before the next update.")
await asyncio.sleep(60) await asyncio.sleep(60)
async def add(self, ws, user_uid): async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid)) s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s) self.sockets.add(s)
@ -76,12 +87,11 @@ class SocketService(BaseService):
async def send_to_user(self, user_uid, message): async def send_to_user(self, user_uid, message):
count = 0 count = 0
for s in self.users.get(user_uid, []): for s in list(self.users.get(user_uid, [])):
if await s.send_json(message): if await s.send_json(message):
count += 1 count += 1
return count return count
async def broadcast(self, channel_uid, message): async def broadcast(self, channel_uid, message):
await self._broadcast(channel_uid, message) await self._broadcast(channel_uid, message)
@ -102,4 +112,3 @@ class SocketService(BaseService):
await s.close() await s.close()
logger.info(f"Removed socket for user {s.user['username']}") logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s) 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

@ -12,6 +12,7 @@ class UserService(BaseService):
async def search(self, query, **kwargs): async def search(self, query, **kwargs):
query = query.strip().lower() query = query.strip().lower()
kwargs["deleted_at"] = None
if not query: if not query:
return [] return []
results = [] results = []
@ -32,7 +33,7 @@ class UserService(BaseService):
user["color"] = await self.services.util.random_light_hex_color() user["color"] = await self.services.util.random_light_hex_color()
return await super().save(user) return await super().save(user)
def authenticate_sync(self,username,password): def authenticate_sync(self, username, password):
user = self.get_by_username_sync(username) user = self.get_by_username_sync(username)
if not user: if not user:
@ -61,8 +62,6 @@ class UserService(BaseService):
return None return None
return path return path
async def get_template_path(self, user_uid): async def get_template_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/templates") path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
if not path.exists(): if not path.exists():

View File

@ -1,48 +1,51 @@
import asyncio
import base64
import json
import logging
import os import os
import aiohttp import shutil
import tempfile
from aiohttp import web from aiohttp import web
import shutil logging.basicConfig(
import json level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
import tempfile )
import asyncio logger = logging.getLogger("git_server")
import logging
import base64
import pathlib
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('git_server')
class GitApplication(web.Application): class GitApplication(web.Application):
def __init__(self, parent=None): def __init__(self, parent=None):
#import git # import git
#globals()['git'] = git # globals()['git'] = git
self.parent = parent self.parent = parent
super().__init__(client_max_size=1024*1024*1024*5) super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes([ self.add_routes(
web.post('/create/{repo_name}', self.create_repository), [
web.delete('/delete/{repo_name}', self.delete_repository), web.post("/create/{repo_name}", self.create_repository),
web.get('/clone/{repo_name}', self.clone_repository), web.delete("/delete/{repo_name}", self.delete_repository),
web.post('/push/{repo_name}', self.push_repository), web.get("/clone/{repo_name}", self.clone_repository),
web.post('/pull/{repo_name}', self.pull_repository), web.post("/push/{repo_name}", self.push_repository),
web.get('/status/{repo_name}', self.status_repository), web.post("/pull/{repo_name}", self.pull_repository),
#web.get('/list', self.list_repositories), web.get("/status/{repo_name}", self.status_repository),
web.get('/branches/{repo_name}', self.list_branches), # web.get('/list', self.list_repositories),
web.post('/branches/{repo_name}', self.create_branch), web.get("/branches/{repo_name}", self.list_branches),
web.get('/log/{repo_name}', self.commit_log), web.post("/branches/{repo_name}", self.create_branch),
web.get('/file/{repo_name}/{file_path:.*}', self.file_content), web.get("/log/{repo_name}", self.commit_log),
web.get('/{path:.+}/info/refs', self.git_smart_http), web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
web.post('/{path:.+}/git-upload-pack', self.git_smart_http), web.get("/{path:.+}/info/refs", self.git_smart_http),
web.post('/{path:.+}/git-receive-pack', self.git_smart_http), web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
web.get('/{repo_name}.git/info/refs', self.git_smart_http), web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http), web.get("/{repo_name}.git/info/refs", self.git_smart_http),
web.post('/{repo_name}.git/git-receive-pack', 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): async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "): if not auth_header.startswith("Basic "):
return None,None return None, None
encoded_creds = auth_header.split("Basic ")[1] encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode() decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1) username, password = decoded_creds.split(":", 1)
@ -50,27 +53,31 @@ class GitApplication(web.Application):
username=username, password=password username=username, password=password
) )
if not request["user"]: if not request["user"]:
return None,None return None, None
request["repository_path"] = await self.parent.services.user.get_repository_path( request["repository_path"] = (
request["user"]["uid"] await self.parent.services.user.get_repository_path(request["user"]["uid"])
) )
return request["user"]['username'],request["repository_path"] return request["user"]["username"], request["repository_path"]
@staticmethod @staticmethod
def require_auth(handler): def require_auth(handler):
async def wrapped(self, request, *args, **kwargs): async def wrapped(self, request, *args, **kwargs):
username, repository_path = await self.check_basic_auth(request) username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path: if not username or not repository_path:
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required') return web.Response(
request['username'] = username status=401,
request['repository_path'] = repository_path headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
request["repository_path"] = repository_path
return await handler(self, request, *args, **kwargs) return await handler(self, request, *args, **kwargs)
return wrapped return wrapped
def repo_path(self, repository_path, repo_name): def repo_path(self, repository_path, repo_name):
return repository_path.joinpath(repo_name + '.git') return repository_path.joinpath(repo_name + ".git")
def check_repo_exists(self, repository_path, repo_name): def check_repo_exists(self, repository_path, repo_name):
repo_dir = self.repo_path(repository_path, repo_name) repo_dir = self.repo_path(repository_path, repo_name)
@ -80,10 +87,10 @@ class GitApplication(web.Application):
@require_auth @require_auth
async def create_repository(self, request): async def create_repository(self, request):
username = request['username'] username = request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
if not repo_name or '/' in repo_name or '..' in repo_name: if not repo_name or "/" in repo_name or ".." in repo_name:
return web.Response(text="Invalid repository name", status=400) return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name) repo_dir = self.repo_path(repository_path, repo_name)
if os.path.exists(repo_dir): if os.path.exists(repo_dir):
@ -98,9 +105,9 @@ class GitApplication(web.Application):
@require_auth @require_auth
async def delete_repository(self, request): async def delete_repository(self, request):
username = request['username'] username = request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
@ -115,9 +122,9 @@ class GitApplication(web.Application):
@require_auth @require_auth
async def clone_repository(self, request): async def clone_repository(self, request):
username = request['username'] request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
@ -126,15 +133,15 @@ class GitApplication(web.Application):
response_data = { response_data = {
"repository": repo_name, "repository": repo_name,
"clone_command": f"git clone {clone_url}", "clone_command": f"git clone {clone_url}",
"clone_url": clone_url "clone_url": clone_url,
} }
return web.json_response(response_data) return web.json_response(response_data)
@require_auth @require_auth
async def push_repository(self, request): async def push_repository(self, request):
username = request['username'] username = request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
@ -142,34 +149,40 @@ class GitApplication(web.Application):
data = await request.json() data = await request.json()
except json.JSONDecodeError: except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400) return web.Response(text="Invalid JSON data", status=400)
commit_message = data.get('commit_message', 'Update from server') commit_message = data.get("commit_message", "Update from server")
branch = data.get('branch', 'main') branch = data.get("branch", "main")
changes = data.get('changes', []) changes = data.get("changes", [])
if not changes: if not changes:
return web.Response(text="No changes provided", status=400) return web.Response(text="No changes provided", status=400)
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir) temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
for change in changes: for change in changes:
file_path = os.path.join(temp_dir, change.get('file', '')) file_path = os.path.join(temp_dir, change.get("file", ""))
content = change.get('content', '') content = change.get("content", "")
os.makedirs(os.path.dirname(file_path), exist_ok=True) os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f: with open(file_path, "w") as f:
f.write(content) f.write(content)
temp_repo.git.add(A=True) temp_repo.git.add(A=True)
if not temp_repo.config_reader().has_section('user'): 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(
temp_repo.config_writer().set_value("user", "email", "git@server.local").release() "user", "name", "Git Server"
).release()
temp_repo.config_writer().set_value(
"user", "email", "git@server.local"
).release()
temp_repo.index.commit(commit_message) temp_repo.index.commit(commit_message)
origin = temp_repo.remote('origin') origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}") origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {username}") logger.info(f"Pushed to repository: {repo_name} for user {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}") return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth @require_auth
async def pull_repository(self, request): async def pull_repository(self, request):
username = request['username'] username = request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
@ -177,13 +190,15 @@ class GitApplication(web.Application):
data = await request.json() data = await request.json()
except json.JSONDecodeError: except json.JSONDecodeError:
data = {} data = {}
remote_url = data.get('remote_url') remote_url = data.get("remote_url")
branch = data.get('branch', 'main') branch = data.get("branch", "main")
if not remote_url: if not remote_url:
return web.Response(text="Remote URL is required", status=400) return web.Response(text="Remote URL is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir) local_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
remote_name = "pull_source" remote_name = "pull_source"
try: try:
remote = local_repo.create_remote(remote_name, remote_url) remote = local_repo.create_remote(remote_name, remote_url)
@ -192,38 +207,46 @@ class GitApplication(web.Application):
remote.set_url(remote_url) remote.set_url(remote_url)
remote.fetch() remote.fetch()
local_repo.git.merge(f"{remote_name}/{branch}") local_repo.git.merge(f"{remote_name}/{branch}")
origin = local_repo.remote('origin') origin = local_repo.remote("origin")
origin.push() origin.push()
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}") logger.info(
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}") 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: except Exception as e:
logger.error(f"Error pulling to {repo_name}: {str(e)}") logger.error(f"Error pulling to {repo_name}: {str(e)}")
return web.Response(text=f"Error pulling changes: {str(e)}", status=500) return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
@require_auth @require_auth
async def status_repository(self, request): async def status_repository(self, request):
username = request['username'] request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), 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] branches = [b.name for b in temp_repo.branches]
active_branch = temp_repo.active_branch.name active_branch = temp_repo.active_branch.name
commits = [] commits = []
for commit in list(temp_repo.iter_commits(max_count=5)): for commit in list(temp_repo.iter_commits(max_count=5)):
commits.append({ commits.append(
{
"id": commit.hexsha, "id": commit.hexsha,
"author": f"{commit.author.name} <{commit.author.email}>", "author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(), "date": commit.committed_datetime.isoformat(),
"message": commit.message "message": commit.message,
}) }
)
files = [] files = []
for root, dirs, filenames in os.walk(temp_dir): for root, dirs, filenames in os.walk(temp_dir):
if '.git' in root: if ".git" in root:
continue continue
for filename in filenames: for filename in filenames:
full_path = os.path.join(root, filename) full_path = os.path.join(root, filename)
@ -234,50 +257,58 @@ class GitApplication(web.Application):
"branches": branches, "branches": branches,
"active_branch": active_branch, "active_branch": active_branch,
"recent_commits": commits, "recent_commits": commits,
"files": files "files": files,
} }
return web.json_response(status_info) return web.json_response(status_info)
except Exception as e: except Exception as e:
logger.error(f"Error getting status for {repo_name}: {str(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) return web.Response(
text=f"Error getting repository status: {str(e)}", status=500
)
@require_auth @require_auth
async def list_repositories(self, request): async def list_repositories(self, request):
username = request['username'] request["username"]
try: try:
repos = [] repos = []
user_dir = self.REPO_DIR user_dir = self.REPO_DIR
if os.path.exists(user_dir): if os.path.exists(user_dir):
for item in os.listdir(user_dir): for item in os.listdir(user_dir):
item_path = os.path.join(user_dir, item) item_path = os.path.join(user_dir, item)
if os.path.isdir(item_path) and item.endswith('.git'): if os.path.isdir(item_path) and item.endswith(".git"):
repos.append(item[:-4]) repos.append(item[:-4])
if request.query.get('format') == 'json': if request.query.get("format") == "json":
return web.json_response({"repositories": repos}) return web.json_response({"repositories": repos})
else: else:
return web.Response(text="\n".join(repos) if repos else "No repositories found") return web.Response(
text="\n".join(repos) if repos else "No repositories found"
)
except Exception as e: except Exception as e:
logger.error(f"Error listing repositories: {str(e)}") logger.error(f"Error listing repositories: {str(e)}")
return web.Response(text=f"Error listing repositories: {str(e)}", status=500) return web.Response(
text=f"Error listing repositories: {str(e)}", status=500
)
@require_auth @require_auth
async def list_branches(self, request): async def list_branches(self, request):
username = request['username'] request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), 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] branches = [b.name for b in temp_repo.branches]
return web.json_response({"branches": branches}) return web.json_response({"branches": branches})
@require_auth @require_auth
async def create_branch(self, request): async def create_branch(self, request):
username = request['username'] username = request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
@ -285,145 +316,168 @@ class GitApplication(web.Application):
data = await request.json() data = await request.json()
except json.JSONDecodeError: except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400) return web.Response(text="Invalid JSON data", status=400)
branch_name = data.get('branch_name') branch_name = data.get("branch_name")
start_point = data.get('start_point', 'HEAD') start_point = data.get("start_point", "HEAD")
if not branch_name: if not branch_name:
return web.Response(text="Branch name is required", status=400) return web.Response(text="Branch name is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir) 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.branch(branch_name, start_point)
temp_repo.git.push('origin', branch_name) temp_repo.git.push("origin", branch_name)
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}") logger.info(
f"Created branch {branch_name} in repository {repo_name} for user {username}"
)
return web.Response(text=f"Created branch {branch_name}") return web.Response(text=f"Created branch {branch_name}")
except Exception as e: except Exception as e:
logger.error(f"Error creating branch {branch_name} in {repo_name}: {str(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) return web.Response(text=f"Error creating branch: {str(e)}", status=500)
@require_auth @require_auth
async def commit_log(self, request): async def commit_log(self, request):
username = request['username'] request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
try: try:
limit = int(request.query.get('limit', 10)) limit = int(request.query.get("limit", 10))
branch = request.query.get('branch', 'main') branch = request.query.get("branch", "main")
except ValueError: except ValueError:
return web.Response(text="Invalid limit parameter", status=400) return web.Response(text="Invalid limit parameter", status=400)
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir) temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
commits = [] commits = []
try: try:
for commit in list(temp_repo.iter_commits(branch, max_count=limit)): for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
commits.append({ commits.append(
{
"id": commit.hexsha, "id": commit.hexsha,
"short_id": commit.hexsha[:7], "short_id": commit.hexsha[:7],
"author": f"{commit.author.name} <{commit.author.email}>", "author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(), "date": commit.committed_datetime.isoformat(),
"message": commit.message.strip() "message": commit.message.strip(),
}) }
)
except git.GitCommandError as e: except git.GitCommandError as e:
if "unknown revision or path" in str(e): if "unknown revision or path" in str(e):
commits = [] commits = []
else: else:
raise raise
return web.json_response({ return web.json_response(
"repository": repo_name, {"repository": repo_name, "branch": branch, "commits": commits}
"branch": branch, )
"commits": commits
})
except Exception as e: except Exception as e:
logger.error(f"Error getting commit log for {repo_name}: {str(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) return web.Response(
text=f"Error getting commit log: {str(e)}", status=500
)
@require_auth @require_auth
async def file_content(self, request): async def file_content(self, request):
username = request['username'] request["username"]
repo_name = request.match_info['repo_name'] repo_name = request.match_info["repo_name"]
file_path = request.match_info.get('file_path', '') file_path = request.match_info.get("file_path", "")
branch = request.query.get('branch', 'main') branch = request.query.get("branch", "main")
repository_path = request['repository_path'] repository_path = request["repository_path"]
error_response = self.check_repo_exists(repository_path, repo_name) error_response = self.check_repo_exists(repository_path, repo_name)
if error_response: if error_response:
return error_response return error_response
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir) temp_repo = git.Repo.clone_from(
self.repo_path(repository_path, repo_name), temp_dir
)
try: try:
temp_repo.git.checkout(branch) temp_repo.git.checkout(branch)
except git.GitCommandError: except git.GitCommandError:
return web.Response(text=f"Branch '{branch}' not found", status=404) return web.Response(text=f"Branch '{branch}' not found", status=404)
file_full_path = os.path.join(temp_dir, file_path) file_full_path = os.path.join(temp_dir, file_path)
if not os.path.exists(file_full_path): if not os.path.exists(file_full_path):
return web.Response(text=f"File '{file_path}' not found", status=404) return web.Response(
text=f"File '{file_path}' not found", status=404
)
if os.path.isdir(file_full_path): if os.path.isdir(file_full_path):
files = os.listdir(file_full_path) files = os.listdir(file_full_path)
return web.json_response({ return web.json_response(
{
"repository": repo_name, "repository": repo_name,
"path": file_path, "path": file_path,
"type": "directory", "type": "directory",
"contents": files "contents": files,
}) }
)
else: else:
try: try:
with open(file_full_path, 'r') as f: with open(file_full_path) as f:
content = f.read() content = f.read()
return web.Response(text=content) return web.Response(text=content)
except UnicodeDecodeError: except UnicodeDecodeError:
return web.Response(text=f"Cannot display binary file content for '{file_path}'", status=400) return web.Response(
text=f"Cannot display binary file content for '{file_path}'",
status=400,
)
except Exception as e: except Exception as e:
logger.error(f"Error getting file content from {repo_name}: {str(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) return web.Response(
text=f"Error getting file content: {str(e)}", status=500
)
@require_auth @require_auth
async def git_smart_http(self, request): async def git_smart_http(self, request):
username = request['username'] request["username"]
repository_path = request['repository_path'] repository_path = request["repository_path"]
path = request.path path = request.path
async def get_repository_path(): async def get_repository_path():
req_path = path.lstrip('/') req_path = path.lstrip("/")
if req_path.endswith('/info/refs'): if req_path.endswith("/info/refs"):
repo_name = req_path[:-len('/info/refs')] repo_name = req_path[: -len("/info/refs")]
elif req_path.endswith('/git-upload-pack'): elif req_path.endswith("/git-upload-pack"):
repo_name = req_path[:-len('/git-upload-pack')] repo_name = req_path[: -len("/git-upload-pack")]
elif req_path.endswith('/git-receive-pack'): elif req_path.endswith("/git-receive-pack"):
repo_name = req_path[:-len('/git-receive-pack')] repo_name = req_path[: -len("/git-receive-pack")]
else: else:
repo_name = req_path repo_name = req_path
if repo_name.endswith('.git'): if repo_name.endswith(".git"):
repo_name = repo_name[:-4] repo_name = repo_name[:-4]
repo_name = repo_name[4:] repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git") repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(f"Resolved repo path: {repo_dir}") logger.info(f"Resolved repo path: {repo_dir}")
return repo_dir return repo_dir
async def handle_info_refs(service): async def handle_info_refs(service):
repo_path = await get_repository_path() repo_path = await get_repository_path()
logger.info(f"handle_info_refs: {repo_path}") logger.info(f"handle_info_refs: {repo_path}")
if not os.path.exists(repo_path): if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404) return web.Response(text="Repository not found", status=404)
cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)] cmd = [service, "--stateless-rpc", "--advertise-refs", str(repo_path)]
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
if process.returncode != 0: if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}") logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500) return web.Response(
text=f"Git error: {stderr.decode()}", status=500
)
response = web.StreamResponse( response = web.StreamResponse(
status=200, status=200,
reason='OK', reason="OK",
headers={ headers={
'Content-Type': f'application/x-{service}-advertisement', "Content-Type": f"application/x-{service}-advertisement",
'Cache-Control': 'no-cache' "Cache-Control": "no-cache",
} },
) )
await response.prepare(request) await response.prepare(request)
packet = f"# service={service}\n" packet = f"# service={service}\n"
@ -435,48 +489,58 @@ class GitApplication(web.Application):
except Exception as e: except Exception as e:
logger.error(f"Error handling info/refs: {str(e)}") logger.error(f"Error handling info/refs: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500) return web.Response(text=f"Server error: {str(e)}", status=500)
async def handle_service_rpc(service): async def handle_service_rpc(service):
repo_path = await get_repository_path() repo_path = await get_repository_path()
logger.info(f"handle_service_rpc: {repo_path}") logger.info(f"handle_service_rpc: {repo_path}")
if not os.path.exists(repo_path): if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404) return web.Response(text="Repository not found", status=404)
if not request.headers.get('Content-Type') == f'application/x-{service}-request': if (
not request.headers.get("Content-Type")
== f"application/x-{service}-request"
):
return web.Response(text="Invalid Content-Type", status=403) return web.Response(text="Invalid Content-Type", status=403)
body = await request.read() body = await request.read()
cmd = [service, '--stateless-rpc', str(repo_path)] cmd = [service, "--stateless-rpc", str(repo_path)]
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await process.communicate(input=body) stdout, stderr = await process.communicate(input=body)
if process.returncode != 0: if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}") logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
return web.Response( return web.Response(
body=stdout, text=f"Git error: {stderr.decode()}", status=500
content_type=f'application/x-{service}-result' )
return web.Response(
body=stdout, content_type=f"application/x-{service}-result"
) )
except Exception as e: except Exception as e:
logger.error(f"Error handling service RPC: {str(e)}") logger.error(f"Error handling service RPC: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500) 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 request.method == "GET" and path.endswith("/info/refs"):
if service in ('git-upload-pack', 'git-receive-pack'): service = request.query.get("service")
if service in ("git-upload-pack", "git-receive-pack"):
return await handle_info_refs(service) return await handle_info_refs(service)
else: else:
return web.Response(text="Smart HTTP requires service parameter", status=400) return web.Response(
elif request.method == 'POST' and '/git-upload-pack' in path: text="Smart HTTP requires service parameter", status=400
return await handle_service_rpc('git-upload-pack') )
elif request.method == 'POST' and '/git-receive-pack' in path: elif request.method == "POST" and "/git-upload-pack" in path:
return await handle_service_rpc('git-receive-pack') 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) return web.Response(text="Not found", status=404)
if __name__ == '__main__':
if __name__ == "__main__":
try: try:
import uvloop import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logger.info("Using uvloop for improved performance") logger.info("Using uvloop for improved performance")
except ImportError: except ImportError:

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)

View File

@ -2,28 +2,28 @@ import aiohttp
ENABLED = False ENABLED = False
import aiohttp
import asyncio import asyncio
from aiohttp import web import json
import sqlite3 import sqlite3
import dataset import aiohttp
from aiohttp import web
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
import json
queue = asyncio.Queue() queue = asyncio.Queue()
class State: class State:
do_not_sync = False do_not_sync = False
async def sync_service(app): async def sync_service(app):
if not ENABLED: if not ENABLED:
return return
session = aiohttp.ClientSession() session = aiohttp.ClientSession()
async with session.ws_connect('http://localhost:3131/ws') as ws: async with session.ws_connect("http://localhost:3131/ws") as ws:
async def receive(): async def receive():
queries_synced = 0 queries_synced = 0
@ -42,20 +42,24 @@ async def sync_service(app):
except Exception as e: except Exception as e:
print(e) print(e)
pass pass
#print(f"Received: {msg.data}") # print(f"Received: {msg.data}")
elif msg.type == aiohttp.WSMsgType.ERROR: elif msg.type == aiohttp.WSMsgType.ERROR:
break break
async def write(): async def write():
while True: while True:
msg = await queue.get() msg = await queue.get()
await ws.send_str(json.dumps(msg,default=str)) await ws.send_str(json.dumps(msg, default=str))
queue.task_done() queue.task_done()
await asyncio.gather(receive(), write()) await asyncio.gather(receive(), write())
await session.close() await session.close()
queries_queued = 0 queries_queued = 0
# Attach a listener to log all executed statements # Attach a listener to log all executed statements
@event.listens_for(Engine, "before_cursor_execute") @event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
@ -63,7 +67,7 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
return return
global queries_queued global queries_queued
if State.do_not_sync: if State.do_not_sync:
print(statement,parameters) print(statement, parameters)
return return
if statement.startswith("SELECT"): if statement.startswith("SELECT"):
return return
@ -71,17 +75,18 @@ def before_cursor_execute(conn, cursor, statement, parameters, context, executem
queries_queued += 1 queries_queued += 1
print("Queries queued: " + str(queries_queued)) print("Queries queued: " + str(queries_queued))
async def websocket_handler(request): async def websocket_handler(request):
queries_broadcasted = 0 queries_broadcasted = 0
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(request) await ws.prepare(request)
request.app['websockets'].append(ws) request.app["websockets"].append(ws)
async for msg in ws: async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT: if msg.type == aiohttp.WSMsgType.TEXT:
for client in request.app['websockets']: for client in request.app["websockets"]:
if client != ws: if client != ws:
await client.send_str(msg.data) await client.send_str(msg.data)
cursor = request.app['db'].cursor() cursor = request.app["db"].cursor()
data = json.loads(msg.data) data = json.loads(msg.data)
queries_broadcasted += 1 queries_broadcasted += 1
@ -89,28 +94,32 @@ async def websocket_handler(request):
cursor.close() cursor.close()
print("Queries broadcasted: " + str(queries_broadcasted)) print("Queries broadcasted: " + str(queries_broadcasted))
elif msg.type == aiohttp.WSMsgType.ERROR: elif msg.type == aiohttp.WSMsgType.ERROR:
print(f'WebSocket connection closed with exception {ws.exception()}') print(f"WebSocket connection closed with exception {ws.exception()}")
request.app['websockets'].remove(ws) request.app["websockets"].remove(ws)
return ws return ws
app = web.Application()
app['websockets'] = []
app.router.add_get('/ws', websocket_handler) app = web.Application()
app["websockets"] = []
app.router.add_get("/ws", websocket_handler)
async def on_startup(app): async def on_startup(app):
app['db'] = sqlite3.connect('snek.db') app["db"] = sqlite3.connect("snek.db")
print("Server starting...") print("Server starting...")
async def on_cleanup(app): async def on_cleanup(app):
for ws in app['websockets']: for ws in app["websockets"]:
await ws.close() await ws.close()
app['db'].close() app["db"].close()
app.on_startup.append(on_startup) app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup) app.on_cleanup.append(on_cleanup)
if __name__ == '__main__': if __name__ == "__main__":
web.run_app(app, host='127.0.0.1', port=3131) web.run_app(app, host="127.0.0.1", port=3131)

View File

@ -1,25 +1,30 @@
import asyncssh
import logging import logging
from pathlib import Path from pathlib import Path
import asyncssh
global _app global _app
def set_app(app): def set_app(app):
global _app global _app
_app = app _app = app
def get_app(): def get_app():
return _app return _app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
roots = {} roots = {}
class SFTPServer(asyncssh.SFTPServer): class SFTPServer(asyncssh.SFTPServer):
def __init__(self, chan: asyncssh.SSHServerChannel): def __init__(self, chan: asyncssh.SSHServerChannel):
self.root = get_app().services.user.get_home_folder_by_username( self.root = get_app().services.user.get_home_folder_by_username(
chan.get_extra_info('username') chan.get_extra_info("username")
) )
self.root.mkdir(exist_ok=True) self.root.mkdir(exist_ok=True)
self.root = str(self.root) self.root = str(self.root)
@ -30,17 +35,19 @@ class SFTPServer(asyncssh.SFTPServer):
logger.debug(f"Mapping client path {path} to {mapped_path}") logger.debug(f"Mapping client path {path} to {mapped_path}")
return str(mapped_path).encode() return str(mapped_path).encode()
class SSHServer(asyncssh.SSHServer): class SSHServer(asyncssh.SSHServer):
def password_auth_supported(self): def password_auth_supported(self):
return True return True
def validate_password(self, username, password): def validate_password(self, username, password):
logger.debug(f"Validating credentials for user {username}") logger.debug(f"Validating credentials for user {username}")
result = get_app().services.user.authenticate_sync(username,password) result = get_app().services.user.authenticate_sync(username, password)
logger.info(f"Validating credentials for user {username}: {result}") logger.info(f"Validating credentials for user {username}: {result}")
return result return result
async def start_ssh_server(app,host,port):
async def start_ssh_server(app, host, port):
set_app(app) set_app(app)
logger.info("Starting SFTP server setup") logger.info("Starting SFTP server setup")
@ -63,14 +70,12 @@ async def start_ssh_server(app,host,port):
x = await asyncssh.listen( x = await asyncssh.listen(
host=host, host=host,
port=port, port=port,
#process_factory=handle_client, # process_factory=handle_client,
server_host_keys=[key], server_host_keys=[key],
server_factory=SSHServer, server_factory=SSHServer,
sftp_factory=SFTPServer sftp_factory=SFTPServer,
) )
return x return x
except Exception as e: except Exception:
logger.warning(f"Failed to start SFTP server. Already running.") logger.warning(f"Failed to start SFTP server. Already running.")
pass pass

View File

@ -10,7 +10,7 @@
import { Schedule } from "./schedule.js"; import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js"; import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js"; import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
export class RESTClient { export class RESTClient {
debug = false; debug = false;
@ -174,7 +174,15 @@ export class App extends EventHandler {
await this.rpc.ping(...args); await this.rpc.ping(...args);
this.is_pinging = false; 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) { async forcePing(...arg) {
await this.rpc.ping(...args); await this.rpc.ping(...args);
} }

View File

@ -9,6 +9,10 @@ html {
height: 100%; height: 100%;
} }
.hidden {
display: none;
}
.gallery { .gallery {
padding: 50px; padding: 50px;
height: auto; height: auto;
@ -140,7 +144,7 @@ footer {
.chat-messages { .chat-messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column-reverse;
} }
.container { .container {
@ -221,6 +225,55 @@ footer {
hyphens: auto; hyphens: auto;
} }
.message-content .spoiler {
background-color: rgba(255, 255, 255, 0.1);
/*color: transparent;*/
cursor: pointer;
border-radius: 0.5rem;
padding: 0.5rem;
position: relative;
height: 2.5rem;
overflow: hidden;
max-width: unset;
}
.message-content .spoiler * {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.spoiler:hover, .spoiler:focus, .spoiler:focus-within, .spoiler:active {
/*color: #e6e6e6;*/
/*transition: color 0.3s ease-in;*/
height: unset;
overflow: unset;
}
@keyframes delay-pointer-events {
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
.spoiler:hover * {
animation: unset;
}
.spoiler:hover *, .spoiler:focus *, .spoiler:focus-within *, .spoiler:active * {
opacity: 1;
transition: opacity 0.3s ease-in;
pointer-events: auto;
visibility: visible;
animation: delay-pointer-events 0.2s linear;
}
.message-content { .message-content {
max-width: 100%; max-width: 100%;
} }
@ -315,7 +368,7 @@ input[type="text"], .chat-input textarea {
} }
} }
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) { .message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
.time { .time {
display: block; display: block;
opacity: 1; opacity: 1;
@ -542,7 +595,31 @@ dialog .dialog-button.secondary {
dialog .dialog-button.secondary:hover { dialog .dialog-button.secondary:hover {
background-color: #f0b84c; 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 { .embed-url-link {
display: flex; display: flex;
@ -585,3 +662,7 @@ dialog .dialog-button.secondary:hover {
text-decoration: none; text-decoration: none;
margin-top: 10px; margin-top: 10px;
} }
th {
min-width: 100px;
}

View File

@ -1,30 +1,40 @@
import { app } from "../app.js"; import { app } from "./app.js";
import { NjetComponent,eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
class ChatInputComponent extends HTMLElement { class ChatInputComponent extends NjetComponent {
autoCompletions = { autoCompletions = {
"example 1": () => {}, "example 1": () => {},
"example 2": () => {}, "example 2": () => {},
} };
hiddenCompletions = { hiddenCompletions = {
"/starsRender": () => { "/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ","")) app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
},
"/leet": () => {
this.value = this.textToLeet(this.value);
this._leetSpeak = !this._leetSpeak;
},
"/l33t": () => {
this._leetSpeakAdvanced = !this._leetSpeakAdvanced;
} }
} };
users = []
textarea = null users = [];
_value = "" textarea = null;
lastUpdateEvent = null _value = "";
previousValue = "" lastUpdateEvent = null;
lastChange = null queuedMessage = null;
changed = false lastMessagePromise = null;
_leetSpeak = false;
_leetSpeakAdvanced = false;
constructor() { constructor() {
super(); super();
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea"); this.textarea = document.createElement("textarea");
this.textarea.classList.add("chat-input-textarea");
this.value = this.getAttribute("value") || ""; this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
} }
get value() { get value() {
@ -32,24 +42,27 @@ class ChatInputComponent extends HTMLElement {
} }
set value(value) { set value(value) {
this._value = value || ""; this._value = value;
this.textarea.value = this._value; this.textarea.value = this._value;
} }
get allAutoCompletions() { get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions) return Object.assign({}, this.autoCompletions, this.hiddenCompletions);
} }
resolveAutoComplete() {
let count = 0; resolveAutoComplete(input) {
let value = null; let value = null;
Object.keys(this.allAutoCompletions).forEach((key) => { for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(this.value.split(" ")[0])) { if (key.startsWith(input.split(" ", 1)[0])) {
count++; if (value) {
return null;
}
value = key; value = key;
} }
}); }
if (count == 1) return value;
return null; return value;
} }
isActive() { isActive() {
@ -59,26 +72,15 @@ class ChatInputComponent extends HTMLElement {
focus() { focus() {
this.textarea.focus(); this.textarea.focus();
} }
getAuthors(){
let authors = []
for (let i = 0; i < this.users.length; i++) {
authors.push(this.users[i].username)
authors.push(this.users[i].nick)
}
return authors
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick]);
} }
extractMentions(text) { extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g; return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
} }
return mentions;
}
matchMentionsToAuthors(mentions, authors) { matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => { return mentions.map(mention => {
let closestAuthor = null; let closestAuthor = null;
@ -89,23 +91,21 @@ class ChatInputComponent extends HTMLElement {
const lowerAuthor = author.toLowerCase(); const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor); let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
if(!this.isSubsequence(lowerMention,lowerAuthor)) { distance += 10;
distance += 10
} }
if (distance < minDistance) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
closestAuthor = author; closestAuthor = author;
} }
}); });
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
}); });
} }
levenshteinDistance(a, b) {
levenshteinDistance(a, b) {
const matrix = []; const matrix = [];
// Initialize the first row and column // Initialize the first row and column
@ -132,9 +132,7 @@ levenshteinDistance(a, b) {
} }
return matrix[b.length][a.length]; return matrix[b.length][a.length];
} }
replaceMentionsWithAuthors(text) { replaceMentionsWithAuthors(text) {
const authors = this.getAuthors(); const authors = this.getAuthors();
@ -148,35 +146,138 @@ levenshteinDistance(a, b) {
}); });
return updatedText; return updatedText;
}
textToLeet(text) {
// L33t speak character mapping
const leetMap = {
'a': '4',
'A': '4',
'e': '3',
'E': '3',
'i': '1',
'I': '1',
'o': '0',
'O': '0',
's': '5',
'S': '5',
't': '7',
'T': '7',
'l': '1',
'L': '1',
'g': '9',
'G': '9',
'b': '6',
'B': '6',
'z': '2',
'Z': '2'
};
// Convert text to l33t speak
return text.split('').map(char => {
return leetMap[char] || char;
}).join('');
} }
// Advanced version with random character selection
textToLeetAdvanced(text) {
const leetMap = {
'a': ['4', '@', '/\\'],
'A': ['4', '@', '/\\'],
'e': ['3', '€'],
'E': ['3', '€'],
'i': ['1', '!', '|'],
'I': ['1', '!', '|'],
'o': ['0', '()'],
'O': ['0', '()'],
's': ['5', '$'],
'S': ['5', '$'],
't': ['7', '+'],
'T': ['7', '+'],
'l': ['1', '|'],
'L': ['1', '|'],
'g': ['9', '6'],
'G': ['9', '6'],
'b': ['6', '|3'],
'B': ['6', '|3'],
'z': ['2'],
'Z': ['2'],
'h': ['#', '|-|'],
'H': ['#', '|-|'],
'n': ['|\\|'],
'N': ['|\\|'],
'm': ['|\\/|'],
'M': ['|\\/|'],
'w': ['\\/\\/'],
'W': ['\\/\\/'],
'v': ['\\/', 'V'],
'V': ['\\/', 'V'],
'u': ['|_|'],
'U': ['|_|'],
'r': ['|2'],
'R': ['|2'],
'f': ['|='],
'F': ['|='],
'd': ['|)'],
'D': ['|)'],
'c': ['(', '['],
'C': ['(', '['],
'k': ['|<'],
'K': ['|<'],
'p': ['|>'],
'P': ['|>'],
'x': ['><'],
'X': ['><'],
'y': ['`/'],
'Y': ['`/']
};
return text.split('').map(char => {
const options = leetMap[char];
if (options) {
return options[Math.floor(Math.random() * options.length)];
}
return char;
}).join('');
}
async connectedCallback() { async connectedCallback() {
this.user = null this.user = null;
app.rpc.getUser(null).then((user) => { app.rpc.getUser(null).then((user) => {
this.user=user this.user = user;
}) });
this.liveType = this.getAttribute("live-type") == "true";
const me = this; this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6;
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{ app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users this.users = users;
}) });
this.messageUid = null; this.messageUid = null;
this.classList.add("chat-input"); this.classList.add("chat-input");
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.appendChild(this.fileUploadGrid);
this.textarea.setAttribute("placeholder", "Type a message..."); this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2"); this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea); this.appendChild(this.textarea);
this.ttsButton = document.createElement("stt-button");
this.snekSpeaker = document.createElement("snek-speaker");
this.appendChild(this.snekSpeaker);
this.ttsButton.addEventListener("click", (e) => {
this.snekSpeaker.enable()
});
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button"); this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid); this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => { this.uploadButton.addEventListener("upload", (e) => {
@ -185,95 +286,104 @@ levenshteinDistance(a, b) {
this.uploadButton.addEventListener("uploaded", (e) => { this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e)); this.dispatchEvent(new CustomEvent("uploaded", e));
}); });
this.uploadButton.addEventListener("click", (e) => {
e.preventDefault();
this.fileUploadGrid.openFileDialog();
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
})
this.appendChild(this.uploadButton); this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput(this.value, true).then(
this.updateFromInput("")
)
});
this.subscribe("file-uploads-done", (data)=>{
this.textarea.style.display = "block";
this.uploadButton.style.display = "block";
this.fileUploadGrid.style.display = "none";
let msg =data.reduce((message, file) => {
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
}, '');
app.rpc.sendMessage(this.channelUid, msg, true);
});
this.textarea.addEventListener("change",(e)=>{
this.value = this.textarea.value;
this.updateFromInput(e.target.value);
})
this.textarea.addEventListener("keyup", (e) => { this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) { 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 = ""; this.value = "";
e.target.value = ""; e.target.value = "";
return; return;
} }
this.value = e.target.value;
this.changed = true; this.finalizeMessage(this.messageUid);
this.update(); return;
}
this.updateFromInput(e.target.value);
}); });
this.textarea.addEventListener("keydown", (e) => { this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value; this.value = e.target.value;
let autoCompletion = null; let autoCompletion = null;
if (e.key === "Tab") { if (e.key === "Tab") {
e.preventDefault(); e.preventDefault();
autoCompletion = this.resolveAutoComplete(); autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) { if (autoCompletion) {
e.target.value = autoCompletion; e.target.value = autoCompletion;
this.value = autoCompletion; this.value = autoCompletion;
return; return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
const message = me.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
return;
} }
this.updateMessage() if (e.repeat) {
app.rpc.finalizeMessage(this.messageUid) this.updateFromInput(e.target.value);
this.value = "";
this.previousValue = "";
this.messageUid = null;
} }
}); });
this.changeInterval = setInterval(() => {
if (!this.liveType) {
return;
}
if (this.value !== this.previousValue) {
if (
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval
) {
this.value = "";
this.previousValue = "";
}
this.lastChange = new Date();
}
this.update();
}, 300);
this.addEventListener("upload", (e) => { this.addEventListener("upload", (e) => {
this.focus(); this.focus();
}); });
this.addEventListener("uploaded", function (e) { this.addEventListener("uploaded", function (e) {
let message = ""; let message = e.detail.files.reduce((message, file) => {
e.detail.files.forEach((file) => { return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
message += `[${file.name}](/channel/attachment/${file.relative_url})`; }, '');
app.rpc.sendMessage(this.channelUid, message, true);
}); });
app.rpc.sendMessage(this.channelUid, message,true);
}); setTimeout(() => {
setTimeout(()=>{
this.focus(); this.focus();
},1000) }, 1000);
} }
trackSecondsBetweenEvents(event1Time, event2Time) { trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime(); const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000; return millisecondsDifference / 1000;
} }
isSubsequence(s, t) { isSubsequence(s, t) {
let i = 0, j = 0; let i = 0, j = 0;
while (i < s.length && j < t.length) { while (i < s.length && j < t.length) {
@ -285,89 +395,47 @@ levenshteinDistance(a, b) {
return i === s.length; return i === s.length;
} }
flagTyping() {
newMessage() { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
if (!this.messageUid) {
this.messageUid = "?";
}
this.value = this.replaceMentionsWithAuthors(this.value);
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
if (this.liveType) {
this.messageUid = uid;
}
});
}
updateMessage() {
if (this.value[0] == "/") {
return false;
}
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === "?") {
return false;
}
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
}
}
updateStatus() {
if (this.liveType) {
return;
}
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
if ( app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.set_typing === "function"
) {
app.rpc.set_typing(this.channelUid, this.user.color);
}
} }
} }
update() { finalizeMessage(messageUid) {
const expired = let value = this.value;
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= value = this.replaceMentionsWithAuthors(value)
this.liveTypeInterval; if(this._leetSpeak){
const changed = this.value !== this.previousValue; value = this.textToLeet(value);
}else if(this._leetSpeakAdvanced){
if (changed || expired) { value = this.textToLeetAdvanced(value);
this.lastChange = new Date();
this.updateStatus();
} }
app.rpc.sendMessage(this.channelUid, value , true);
this.previousValue = this.value;
if (this.liveType && expired) {
this.value = ""; this.value = "";
this.previousValue = "";
this.messageUid = null; this.messageUid = null;
return; this.queuedMessage = null;
this.lastMessagePromise = null;
} }
if (changed) {
if (this.liveType) {
this.updateMessage(); updateFromInput(value, isFinal = false) {
}
this.value = value;
this.flagTyping();
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid;
} }
} }
async sendMessage(channelUid, value,is_final) { async sendMessage(channelUid, value, is_final) {
if (!value.trim()) { return await app.rpc.sendMessage(channelUid, value, is_final);
return null;
}
return await app.rpc.sendMessage(channelUid, value,is_final);
} }
} }
customElements.define("chat-input", ChatInputComponent); customElements.define("chat-input", ChatInputComponent);

View File

@ -0,0 +1,113 @@
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()
}
fit(){
this._fitAddon.fit();
}
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));
})
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/container/sock/${this.channelUid}.json`;
this.ws = new WebSocket(wsUrl);
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

@ -1,13 +1,32 @@
class DumbTerminal extends HTMLElement { import { NjetComponent } from "/njet.js";
constructor() { class WebTerminal extends NjetComponent {
super();
this.attachShadow({ mode: "open" }); commands = {
"clear": () => {
this.outputEl.innerHTML = "";
return "";
},
"help": () => {
return "Available commands: help, clear, date";
},
"date": () => {
return new Date().toString();
} }
};
help = {
"clear": "Clear the terminal",
"help": "Show available commands",
"date": "Show the current date"
};
connectedCallback() { connectedCallback() {
this.shadowRoot.innerHTML = `
this.innerHTML = `
<style> <style>
:host { .web-terminal {
--terminal-bg: #111; --terminal-bg: #111;
--terminal-fg: #0f0; --terminal-fg: #0f0;
--terminal-accent: #0ff; --terminal-accent: #0ff;
@ -23,7 +42,7 @@ class DumbTerminal extends HTMLElement {
max-height: 500px; max-height: 500px;
} }
.output { .web-terminal-output {
white-space: pre-wrap; white-space: pre-wrap;
margin-bottom: 1em; margin-bottom: 1em;
} }
@ -37,7 +56,7 @@ class DumbTerminal extends HTMLElement {
margin-right: 0.5em; margin-right: 0.5em;
} }
input { .web-terminal-input {
background: transparent; background: transparent;
border: none; border: none;
color: var(--terminal-fg); color: var(--terminal-fg);
@ -57,21 +76,22 @@ class DumbTerminal extends HTMLElement {
padding: 2rem; padding: 2rem;
} }
</style> </style>
<div class="web-terminal">
<div class="output" id="output"></div> <div class="web-terminal-output" id="output"></div>
<div class="input-line"> <div class="input-line">
<span class="prompt">&gt;</span> <span class="prompt">&gt;</span>
<input type="text" id="input" autocomplete="off" autofocus /> <input type="text" class="web-terminal-input" id="input" autocomplete="off" autofocus />
</div>
</div> </div>
`; `;
this.container = this.querySelector(".web-terminal");
this.outputEl = this.shadowRoot.getElementById("output"); this.outputEl = this.querySelector(".web-terminal-output");
this.inputEl = this.shadowRoot.getElementById("input"); this.inputEl = this.querySelector(".web-terminal-input");
this.history = []; this.history = [];
this.historyIndex = -1; this.historyIndex = -1;
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e)); this.inputEl.addEventListener("keyup", (e) => this.onKeyDown(e));
} }
onKeyDown(event) { onKeyDown(event) {
@ -116,17 +136,89 @@ class DumbTerminal extends HTMLElement {
this.outputEl.scrollTop = this.outputEl.scrollHeight; this.outputEl.scrollTop = this.outputEl.scrollHeight;
} }
parseCommand(input) {
const args = [];
let current = '';
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false; // For ${...}
let templateBuffer = '';
for (let i = 0; i < input.length; i++) {
const char = input[i];
// Handle template expressions
if (!inSingleQuote && !inDoubleQuote && char === '$' && input[i + 1] === '{') {
inTemplate = true;
i++; // Skip '{'
templateBuffer = '${';
continue;
}
if (inTemplate) {
templateBuffer += char;
if (char === '}') {
// End of template
args.push(eval(templateBuffer));
inTemplate = false;
templateBuffer = '';
}
continue; // Continue to next char
}
// Handle quotes
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue; // Skip quote
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue; // Skip quote
}
// Handle spaces outside quotes and templates
if (char === ' ' && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current.length > 0) {
args.push(current);
}
return args;
}
executeScript(script) {
const scriptElement = document.createElement("script");
scriptElement.textContent = script;
this.appendChild(scriptElement);
}
mockExecute(command) { mockExecute(command) {
switch (command.trim()) { let args;
case "help": try {
return "Available commands: help, clear, date"; args = this.parseCommand(command);
case "date": } catch (e) {
return new Date().toString(); return e.toString();
case "clear": }
this.outputEl.innerHTML = ""; console.info({ef:this})
return ""; console.info({af:this})
default: console.info({gf:args})
return `Unknown command: ${command}`; let cmdName = args.shift();
let commandHandler = this.commands[cmdName];
if (commandHandler) {
return commandHandler.apply(this, args);
}
try {
// Try to eval as JS
return this.executeScript(command);
} catch (e) {
return e.toString();
} }
} }
@ -134,7 +226,7 @@ class DumbTerminal extends HTMLElement {
* Static method to create a modal dialog with the terminal * Static method to create a modal dialog with the terminal
* @returns {HTMLDialogElement} * @returns {HTMLDialogElement}
*/ */
static createModal() { show() {
const dialog = document.createElement("dialog"); const dialog = document.createElement("dialog");
dialog.innerHTML = ` dialog.innerHTML = `
<div class="dialog-backdrop"> <div class="dialog-backdrop">
@ -147,4 +239,12 @@ class DumbTerminal extends HTMLElement {
} }
} }
window.showTerm = function (options) {
const term = new WebTerminal(options);
term.show();
return term;
}
customElements.define("web-terminal", WebTerminal); customElements.define("web-terminal", WebTerminal);
export { WebTerminal };

494
src/snek/static/editor.js Normal file
View File

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

View File

@ -3,8 +3,15 @@ export class EventHandler {
this.subscribers = {}; this.subscribers = {};
} }
addEventListener(type, handler) { addEventListener(type, handler, { once = false } = {}) {
if (!this.subscribers[type]) this.subscribers[type] = []; if (!this.subscribers[type]) this.subscribers[type] = [];
if (once) {
const originalHandler = handler;
handler = (...args) => {
originalHandler(...args);
this.removeEventListener(type, handler);
};
}
this.subscribers[type].push(handler); this.subscribers[type].push(handler);
} }
@ -12,4 +19,15 @@ export class EventHandler {
if (this.subscribers[type]) if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data)); this.subscribers[type].forEach((handler) => handler(...data));
} }
removeEventListener(type, handler) {
if (!this.subscribers[type]) return;
this.subscribers[type] = this.subscribers[type].filter(
(h) => h !== handler
);
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
}
}
} }

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

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,6 @@
/* A <file-browser> custom element that talks to /api/files */ /* A <file-browser> custom element that talks to /api/files */
import { NjetComponent } from "/njet.js";
class FileBrowser extends HTMLElement { class FileBrowser extends HTMLElement {
constructor() { constructor() {
super(); super();
@ -6,9 +8,11 @@ class FileBrowser extends HTMLElement {
this.path = ""; // current virtual path ("" = ROOT) this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset this.offset = 0; // pagination offset
this.limit = 40; // items per request this.limit = 40; // items per request
this.url = '/drive.json'
} }
connectedCallback() { connectedCallback() {
this.url = this.getAttribute("url") || this.url;
this.path = this.getAttribute("path") || ""; this.path = this.getAttribute("path") || "";
this.renderShell(); this.renderShell();
this.load(); this.load();
@ -58,7 +62,7 @@ class FileBrowser extends HTMLElement {
// ---------- Networking ---------------------------------------------- // ---------- Networking ----------------------------------------------
async load() { async load() {
const r = await fetch( const r = await fetch(
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`, this.url + `?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`
); );
if (!r.ok) { if (!r.ok) {
console.error(await r.text()); console.error(await r.text());

View File

@ -0,0 +1,59 @@
.fug-root {
background: #181818;
color: white;
font-family: sans-serif;
/*min-height: 100vh;*/
}
.fug-grid {
display: grid;
grid-template-columns: repeat(6, 150px);
gap: 20px;
margin: 30px;
}
.fug-tile {
background: #111;
border: 2px solid #ff6600;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px 8px 8px;
box-shadow: 0 0 4px #333;
position: relative;
}
.fug-icon {
width: 48px; height: 48px;
margin-bottom: 8px;
object-fit: cover;
background: #222;
border-radius: 5px;
}
.fug-filename {
word-break: break-all;
margin-bottom: 8px;
font-size: 0.95em;
text-align: center;
}
.fug-progressbar {
width: 100%;
height: 7px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-top: auto;
}
.fug-progress {
height: 100%;
background: #ffb200;
transition: width 0.15s;
width: 0%;
}
.fug-tile.fug-done {
border-color: #0f0;
}
.fug-fileinput {
margin: 24px;
font-size: 1.1em;
display: none;
}

View File

@ -0,0 +1,180 @@
import { NjetComponent, NjetDialog } from '/njet.js';
const FUG_ICONS = {
file: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect x="8" y="8" width="32" height="40" rx="5" fill="white" stroke="%234af" stroke-width="2"/></svg>'
};
class FileUploadGrid extends NjetComponent {
constructor() {
super();
this._grid = null;
this._fileInput = null;
this.channelUid = null ;
this.uploadsDone = 0;
this.uploadsStarted = 0;
this.uploadResponses = [];
}
openFileDialog() {
if(this.isBusy){
const dialog = new NjetDialog({
title: 'Upload in progress',
content: 'Please wait for the current upload to complete.',
primaryButton: {
text: 'OK',
handler: function () {
this.closest('njet-dialog').remove();
}
}
})
dialog.open();
return false
}
if (this._fileInput) {
this._fileInput.value = ''; // Allow same file selection twice
this._fileInput.click();
}
return true;
}
render() {
// Root wrapper for styling
this.classList.add('fug-root');
// Clear previous (if rerendered)
this.innerHTML = '';
// Input
this._fileInput = document.createElement('input');
this._fileInput.type = 'file';
this._fileInput.className = 'fug-fileinput';
this._fileInput.multiple = true;
// Grid
this._grid = document.createElement('div');
this._grid.className = 'fug-grid';
this.appendChild(this._fileInput);
this.appendChild(this._grid);
this.uploadsDone = 0;
this.uploadsStarted = 0;
}
reset(){
this.uploadResponses = [];
this.uploadsDone = 0;
this.uploadsStarted = 0;
this._grid.innerHTML = '';
}
get isBusy(){
return this.uploadsDone != this.uploadsStarted;
}
handleFiles(files) {
this.reset()
this.uploadsDone = 0;
this.uploadsStarted = files.length;
[...files].forEach(file => this.createTile(file));
}
connectedCallback() {
this.channelUid = this.getAttribute('channel');
this.render();
this._fileInput.addEventListener('change', e => this.handleFiles(e.target.files));
}
createTile(file) {
const tile = document.createElement('div');
tile.className = 'fug-tile';
// Icon/Thumbnail
let icon;
if (file.type.startsWith('image/')) {
icon = document.createElement('img');
icon.className = 'fug-icon';
icon.src = URL.createObjectURL(file);
icon.onload = () => URL.revokeObjectURL(icon.src);
} else if (file.type.startsWith('video/')) {
icon = document.createElement('video');
icon.className = 'fug-icon';
icon.src = URL.createObjectURL(file);
icon.muted = true;
icon.playsInline = true;
icon.controls = false;
icon.preload = 'metadata';
icon.onloadeddata = () => {
icon.currentTime = 0.5;
URL.revokeObjectURL(icon.src);
};
} else {
icon = document.createElement('img');
icon.className = 'fug-icon';
icon.src = FUG_ICONS.file;
}
// Filename
const name = document.createElement('div');
name.className = 'fug-filename';
name.textContent = file.name;
// Progressbar
const progressbar = document.createElement('div');
progressbar.className = 'fug-progressbar';
const progress = document.createElement('div');
progress.className = 'fug-progress';
progressbar.appendChild(progress);
// Tile composition
tile.appendChild(icon);
tile.appendChild(name);
tile.appendChild(progressbar);
this._grid.appendChild(tile);
// Start upload
this.startUpload(file, tile, progress);
}
startUpload(file, tile, progress) {
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
console.info("File uploading",file)
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`);
ws.binaryType = 'arraybuffer';
let sent = 0;
ws.onopen = async () => {
ws.send(JSON.stringify({type: 'start', filename: file.name}));
const chunkSize = 64*1024;
while (sent < file.size) {
const chunk = file.slice(sent, sent + chunkSize);
ws.send(await chunk.arrayBuffer());
sent += chunkSize;
}
ws.send(JSON.stringify({type: 'end', filename: file.name}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
const pct = Math.min(100, Math.round(100 * data.bytes / file.size));
progress.style.width = pct + '%';
this.publish('file-uploading', {file: file, tile: tile, progress: progress});
} else if (data.type === 'done') {
this.uploadsDone += 1;
this.publish('file-uploaded', {file: file, tile: tile, progress: progress});
progress.style.width = '100%';
tile.classList.add('fug-done');
ws.close();
this.uploadResponses.push({file:file, remoteFile:data.file})
if(this.uploadsDone == this.uploadsStarted){
this.publish('file-uploads-done', this.uploadResponses);
console.info({"X":this.uploadResponses})
this.reset()
}
}
};
}
}
customElements.define('file-upload-grid', FileUploadGrid);
export { FileUploadGrid };

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,30 +1,58 @@
{ {
"id": "snek", "id": "snek",
"name": "Snek", "name": "Snek",
"description": "Danger noodle", "short_name": "Snek",
"description": "Snek Software Development Community",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"scope": "/", "scope": "/",
"start_url": "/web.html",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#000000", "background_color": "#000000",
"related_applications": [],
"prefer_related_applications": false,
"screenshots": [],
"dir": "ltr", "dir": "ltr",
"lang": "en-US", "lang": "en-US",
"launch_path": "/web.html",
"short_name": "Snek",
"start_url": "/web.html",
"icons": [ "icons": [
{ {
"src": "/image/snek192.png", "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", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "/image/snek512.png", "src": "/image/snek_logo_256x256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "/image/snek_logo_512x512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
},
{
"src": "/image/snek_logo_1024x1024.png",
"type": "image/png",
"sizes": "1024x1024"
} }
] ],
} "related_applications": [],
"prefer_related_applications": false
}

View File

@ -5,40 +5,228 @@
// 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. // 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. // MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "../app.js"; import { app } from "./app.js";
class MessageList extends HTMLElement {
constructor() { const LONG_TIME = 1000 * 60 * 20;
super();
app.ws.addEventListener("update_message_text", (data) => { export class ReplyEvent extends Event {
this.updateMessageText(data.uid, data); constructor(messageTextTarget) {
}); super('reply', { bubbles: true, composed: true });
app.ws.addEventListener("set_typing", (data) => { this.messageTextTarget = messageTextTarget;
this.triggerGlow(data.user_uid,data.color);
// Clone and sanitize message node to text-only reply
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
// Remove all .embed-url-link
newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
// Replace <picture> with their <img>
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) picture.replaceWith(img);
}); });
this.items = []; // Replace <img> with just their src
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
});
// Replace <iframe> with their src
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
});
// Replace <a> with href or markdown
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
});
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove();
}
}
class MessageElement extends HTMLElement {
updateUI() {
if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid } = this.dataset;
this.classList.add('message');
this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children);
this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
</a>
<div class="message-content">
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
<div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}">
<span></span>
<a href="#reply">reply</a>
</div>
</div>
`;
this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child);
});
}
this.timeDiv = this.querySelector('.time span');
this.replyDiv = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
});
}
// Sibling logic for user switches and long time gaps
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
this.siblingGenerated = this.nextElementSibling;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
this.classList.add('switch-user');
} else {
this.classList.remove('switch-user');
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
const currentTime = new Date(this.dataset.created_at);
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
this.classList.add('long-time');
} else {
this.classList.remove('long-time');
}
}
}
this.timeDiv.innerText = app.timeDescription(this.dataset.created_at);
}
updateMessage(...messages) {
if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages);
this._originalChildren = messages;
}
} }
connectedCallback() { connectedCallback() {
const messagesContainer = this this.updateUI();
messagesContainer.addEventListener('click', (e) => { }
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
disconnectedCallback() {}
connectedMoveCallback() {}
attributeChangedCallback(name, oldValue, newValue) {
this.updateUI();
}
}
class MessageList extends HTMLElement {
constructor() {
super();
this.messageMap = new Map();
this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.visibleSet.add(entry.target);
if (entry.target instanceof MessageElement) {
entry.target.updateUI();
}
} else {
this.visibleSet.delete(entry.target);
}
});
}, {
root: this,
threshold: 0,
});
// End-of-messages marker
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
// Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c);
}
}
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color);
});
this.scrollToBottom(true);
}
connectedCallback() {
this.addEventListener('click', (e) => {
if (
e.target.tagName !== 'IMG' ||
e.target.classList.contains('avatar-img')
) return;
const img = e.target; const img = e.target;
const overlay = document.createElement('div'); 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;' overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
const urlObj = new URL(img.currentSrc || img.src, window.location.origin);
urlObj.searchParams.delete('width');
urlObj.searchParams.delete('height');
const fullImg = document.createElement('img'); const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString(); fullImg.src = urlObj.toString();
fullImg.alt = img.alt; fullImg.alt = img.alt || '';
fullImg.style.maxWidth = '90%'; fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%'; fullImg.style.maxHeight = '90%';
fullImg.style.boxShadow = '0 0 32px #000';
fullImg.style.borderRadius = '8px';
fullImg.style.background = '#222';
fullImg.style.objectFit = 'contain';
fullImg.loading = 'lazy';
overlay.appendChild(fullImg); overlay.appendChild(fullImg);
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
})
overlay.addEventListener('click', () => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
});
// ESC to close
const escListener = (evt) => {
if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', escListener);
} }
};
document.addEventListener('keydown', escListener);
});
}
isElementVisible(element) { isElementVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return ( return (
rect.top >= 0 && rect.top >= 0 &&
@ -46,62 +234,83 @@ class MessageList extends HTMLElement {
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
); );
} }
isScrolledToBottom() { isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom")); return this.visibleSet.has(this.endOfMessages);
} }
scrollToBottom(force) {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView(); scrollToBottom(force = false, behavior = 'instant') {
if (force || !this.isScrolledToBottom()) {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
}, 200);
}
}
this.scrollTop = this.scrollHeight; triggerGlow(uid, color) {
this.querySelector(".message-list-bottom").scrollIntoView(); if (!uid || !color) return;
},200) app.starField.glowColor(color);
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
if (!messageDiv) {
return;
}
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; let lastElement = null;
this.querySelectorAll(".avatar").forEach((el) => { this.querySelectorAll('.avatar').forEach((el) => {
const div = el.closest("a"); const anchor = el.closest('a');
if (el.href.indexOf(uid) != -1) { if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
if(!lastElement)
lastElement = el; lastElement = el;
} }
}); });
if (lastElement) { if (lastElement) {
lastElement.classList.add("glow"); lastElement.classList.add('glow');
setTimeout(() => { setTimeout(() => {
lastElement.classList.remove("glow"); lastElement.classList.remove('glow');
}, 1000); }, 1000);
} }
} }
set data(items) { updateTimes() {
this.items = items; this.visibleSet.forEach((messageElement) => {
this.render(); if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
});
} }
render() {
this.innerHTML = "";
//this.insertAdjacentHTML("beforeend", html); upsertMessage(data) {
let message = this.messageMap.get(data.uid);
if (message && (data.is_final || !data.message)) {
//message.parentElement?.removeChild(message);
// TO force insert
//message = null;
}
if(message && !data.message){
message.parentElement?.removeChild(message);
message = null;
}
if (!data.message) return;
const wrapper = document.createElement("div");
wrapper.innerHTML = data.html;
if (message) {
// If the old element is already custom, only update its message children
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else {
// If not, insert the new one and observe
message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message);
this._observer.observe(message);
this.endOfMessages.after(message);
}
const scrolledToBottom = this.isScrolledToBottom();
if (scrolledToBottom) this.scrollToBottom(true);
} }
} }
customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList); customElements.define("message-list", MessageList);

18
src/snek/static/njet.css Normal file
View File

@ -0,0 +1,18 @@
/* CSS version */
.njet-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
min-width: 33px;
}
.njet-window {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
min-width: 33px;
}

585
src/snek/static/njet.js Normal file
View File

@ -0,0 +1,585 @@
class RestClient {
constructor({ baseURL = '', headers = {} } = {}) {
this.baseURL = baseURL;
this.headers = { ...headers };
// Interceptor containers
this.interceptors = {
request: {
handlers: [],
use: (fn) => {
this.interceptors.request.handlers.push(fn);
}
},
response: {
handlers: [],
use: (successFn, errorFn) => {
this.interceptors.response.handlers.push({ success: successFn, error: errorFn });
}
}
};
}
// Core request method
request(config) {
// Merge defaults
const cfg = {
method: 'GET',
url: '',
data: null,
headers: {},
...config
};
cfg.headers = { ...this.headers, ...cfg.headers };
// Apply request interceptors
let chain = Promise.resolve(cfg);
this.interceptors.request.handlers.forEach((fn) => {
chain = chain.then(fn);
});
// Perform fetch
chain = chain.then((c) => {
const url = this.baseURL + c.url;
const options = { method: c.method, headers: c.headers };
if (c.data != null) {
options.body = JSON.stringify(c.data);
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
}
return fetch(url, options).then(async (response) => {
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
data = text;
}
const result = {
data,
status: response.status,
statusText: response.statusText,
headers: RestClient._parseHeaders(response.headers),
config: c,
request: response
};
if (!response.ok) {
return Promise.reject(result);
}
return result;
});
});
// Apply response interceptors
this.interceptors.response.handlers.forEach(({ success, error }) => {
chain = chain.then(success, error);
});
return chain;
}
// Helper methods for HTTP verbs
get(url, config) {
return this.request({ ...config, method: 'GET', url });
}
delete(url, config) {
return this.request({ ...config, method: 'DELETE', url });
}
head(url, config) {
return this.request({ ...config, method: 'HEAD', url });
}
options(url, config) {
return this.request({ ...config, method: 'OPTIONS', url });
}
post(url, data, config) {
return this.request({ ...config, method: 'POST', url, data });
}
put(url, data, config) {
return this.request({ ...config, method: 'PUT', url, data });
}
patch(url, data, config) {
return this.request({ ...config, method: 'PATCH', url, data });
}
// Utility to parse Fetch headers into an object
static _parseHeaders(headers) {
const result = {};
for (const [key, value] of headers.entries()) {
result[key] = value;
}
return result;
}
}
class Njet extends HTMLElement {
static _root = null
static showDialog = null
get isRoot() {
return Njet._root === this
}
get root() {
return Njet._root
}
get rest() {
return Njet._root._rest
}
attach(element) {
this._attachedTo = element
this._attachedTo.addEventListener("resize", () => {
this.updatePosition()
})
}
updatePosition(){
if(this._attachedTo)
{
this.style.width = `${this._attachedTo.offsetWidth}`
this.style.height = `${this._attachedTo.offsetHeight}`
this.style.left = `${this._attachedTo.offsetLeft}`
this.style.top = `${this._attachedTo.offsetTop}`
this.style.position = 'fixed'
}
}
_subscriptions = {}
_elements = []
_rest = null
_attachedTo = null
match(args) {
return Object.entries(args).every(([key, value]) => this[key] === value);
}
set(key, value) {
this.dataset[key] = value
}
get(key, defaultValue) {
if (this.dataset[key]) {
return this.dataset[key]
}
if (defaultValue === undefined) {
return
}
this.dataset[key] = defaultValue
return this.dataset[key]
}
showDialog(args){
// const dialog = this.createComponent('njet-dialog', args)
// dialog.show()
// return dialog()
}
find(args) {
for (let element of this.root._elements) {
if (element.match(args)) {
return element
}
}
return null
}
findAll(args) {
return this.root._elements.filter(element => element.match(args))
}
subscribe(event, callback) {
if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = []
}
this.root._subscriptions[event].push(callback)
}
publish(event, data) {
if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data))
}
}
static registerComponent(name, component) {
customElements.define(name, component);
}
constructor(config) {
super();
// Store the config for use in render and other methods
this.config = config || {};
if (!Njet._root) {
Njet._root = this
Njet._rest = new RestClient({ baseURL: '/' || null })
}
this.root._elements.push(this)
this.classList.add('njet');
// Initialize properties from config before rendering
this.initProps(this.config);
// Call render after properties are initialized
this.render.call(this);
// Call construct if defined
if (typeof this.config.construct === 'function') {
this.config.construct.call(this)
}
}
initProps(config) {
const props = Object.keys(config)
props.forEach(prop => {
// Skip special properties that are handled separately
if (['construct', 'items', 'classes'].includes(prop)) {
return;
}
// Check if there's a setter for this property
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), prop);
if (descriptor && descriptor.set) {
// Use the setter
this[prop] = config[prop];
} else if (prop in this) {
// Property exists, set it directly
this[prop] = config[prop];
} else {
// Set as attribute for unknown properties
this.setAttribute(prop, config[prop]);
}
});
if (config.classes) {
this.classList.add(...config.classes);
}
}
duplicate() {
const duplicatedConfig = { ...this.config };
if (duplicatedConfig.items) {
duplicatedConfig.items = duplicatedConfig.items.map(item => {
return typeof item.duplicate === 'function' ? item.duplicate() : item;
});
}
return new this.constructor(duplicatedConfig);
}
set width(val) {
this.style.width = typeof val === 'number' ? `${val}px` : val;
}
get width() { return this.style.width; }
set height(val) {
this.style.height = typeof val === 'number' ? `${val}px` : val;
}
get height() { return this.style.height; }
set left(val) {
this.style.position = 'absolute';
this.style.left = typeof val === 'number' ? `${val}px` : val;
}
get left() { return this.style.left; }
set top(val) {
this.style.position = 'absolute';
this.style.top = typeof val === 'number' ? `${val}px` : val;
}
get top() { return this.style.top; }
set opacity(val) { this.style.opacity = val; }
get opacity() { return this.style.opacity; }
set disabled(val) { this.toggleAttribute('disabled', !!val); }
get disabled() { return this.hasAttribute('disabled'); }
set visible(val) { this.style.display = val ? '' : 'none'; }
get visible() { return this.style.display !== 'none'; }
render() {}
}
Njet.registerComponent('njet-root', Njet);
class Component extends Njet {}
Njet.registerComponent('njet-component', Component);
class NjetPanel extends Component {
render() {
this.innerHTML = '';
const { title, items = [] } = this.config;
this.style.border = '1px solid #ccc';
this.style.padding = '10px';
if (title) {
const header = document.createElement('h3');
header.textContent = title;
this.appendChild(header);
}
items.forEach(item => this.appendChild(item));
}
}
Njet.registerComponent('njet-panel', NjetPanel);
class NjetButton extends Component {
render() {
this.classList.add('njet-button');
this.innerHTML = '';
const button = document.createElement('button');
button.textContent = this.config.text || 'Button';
if (typeof this.config.handler === 'function') {
button.addEventListener('click', (event) => this.config.handler.call(this));
}
const observer = new MutationObserver(() => {
button.disabled = this.disabled;
});
observer.observe(this, { attributes: true, attributeFilter: ['disabled'] });
button.disabled = this.disabled;
this.appendChild(button);
}
}
Njet.registerComponent('njet-button', NjetButton);
class NjetDialog extends Component {
render() {
this.innerHTML = '';
const { title, content, primaryButton, secondaryButton } = this.config;
this.classList.add('njet-dialog');
if (title) {
const header = document.createElement('h2');
header.textContent = title;
this.appendChild(header);
}
if (content) {
const body = document.createElement('div');
body.innerHTML = content;
this.appendChild(body);
}
const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px';
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.gap = '10px';
if (secondaryButton) {
const secondary = new NjetButton(secondaryButton);
buttonContainer.appendChild(secondary);
}
if (primaryButton) {
const primary = new NjetButton(primaryButton);
buttonContainer.appendChild(primary);
}
this.appendChild(buttonContainer);
}
show(){
document.body.appendChild(this)
}
}
Njet.registerComponent('njet-dialog', NjetDialog);
class NjetWindow extends Component {
render() {
this.innerHTML = '';
const { title, content, primaryButton, secondaryButton } = this.config;
this.classList.add('njet-window');
if (title) {
const header = document.createElement('h2');
header.textContent = title;
this.appendChild(header);
}
if (this.config.items) {
this.config.items.forEach(item => this.appendChild(item));
}
}
show(){
document.body.appendChild(this)
}
}
Njet.registerComponent('njet-window', NjetWindow);
class NjetGrid extends Component {
render() {
this.classList.add('njet-grid');
this.innerHTML = '';
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
const data = this.config.data || [];
data.forEach(row => {
const tr = document.createElement('tr');
Object.values(row).forEach(cell => {
const td = document.createElement('td');
td.textContent = cell;
td.style.border = '1px solid #ddd';
td.style.padding = '4px';
tr.appendChild(td);
});
table.appendChild(tr);
});
this.appendChild(table);
}
}
Njet.registerComponent('njet-grid', NjetGrid);
/* Example usage:
const button = new NjetButton({
classes: ['my-button'],
text: 'Shared',
tag: 'shared',
width: 120,
height: 30,
handler() {
this.root.findAll({ tag: 'shared' }).forEach(e => {
e.disabled = !e.disabled;
});
}
});
const button2 = new NjetButton({
classes: ['my-button'],
text: 'Single',
iValue: 0,
width: 120,
height: 30,
handler() {
this.iValue++;
const panel = this.closest('njet-panel');
if (panel) {
const h3 = panel.querySelector('h3');
if (h3) h3.textContent = `Click ${this.iValue}`;
}
this.publish("btn2Click", `Click ${this.iValue}`);
}
});
const grid = new NjetGrid({
data: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
],
width: '100%',
visible: true
});
const panel = new NjetPanel({
title: 'My Panel',
items: [button, grid, button2],
left: 50,
top: 50,
construct: function () {
this.subscribe('btn2Click', (data) => {
this._title = data
});
}
});
document.body.appendChild(panel);
const panelClone = panel.duplicate();
const panell = panelClone.duplicate();
panell.left = 120;
panell.width = 300;
panelClone.appendChild(panell);
panelClone.left = 300;
panelClone.top = 50;
document.body.appendChild(panelClone);
const dialog = new NjetDialog({
title: 'Confirm Action',
content: 'Are you sure you want to continue?',
primaryButton: {
text: 'Yes',
handler: function () {
alert('Confirmed');
this.closest('njet-dialog').remove();
}
},
secondaryButton: {
text: 'Cancel',
handler: function () {
this.closest('njet-dialog').remove();
}
}
});
document.body.appendChild(dialog);
*/
class NjetComponent extends Component {}
const njet = Njet
njet.showDialog = function(args){
const dialog = new NjetDialog(args)
dialog.show()
return dialog
}
class EventBus extends EventTarget {
constructor() {
super();
this.eventMap = new Map();
}
subscribe(eventName, callback) {
this.addEventListener(eventName, callback);
if (!this.eventMap.has(eventName)) {
this.eventMap.set(eventName, []);
}
this.eventMap.get(eventName).push(callback);
}
publish(eventName, detail = {}) {
const event = new CustomEvent(eventName, {
detail,
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
unsubscribe(eventName, callback) {
this.removeEventListener(eventName, callback);
const subscribers = this.eventMap.get(eventName);
if (subscribers) {
const index = subscribers.indexOf(callback);
if (index > -1) subscribers.splice(index, 1);
}
}
}
const eventBus = new EventBus()
njet.showWindow = function(args) {
const w = new NjetWindow(args)
w.show()
return w
}
njet.publish = function(event, data) {
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data))
}
}
njet.subscribe = function(event, callback) {
if (!this.root) return;
if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = []
}
this.root._subscriptions[event].push(callback)
}
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus };

View File

@ -1,34 +1,57 @@
this.onpush = (event) => { export const registerServiceWorker = async (silent = false) => {
console.log(event.data); try {
// From here we can write the data to IndexedDB, send it to any open const serviceWorkerRegistration = await navigator.serviceWorker
// windows, display a notification, etc.
};
navigator.serviceWorker
.register("/service-worker.js") .register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then( await serviceWorkerRegistration.update()
(pushSubscription) => {
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 = { const subscriptionObject = {
endpoint: pushSubscription.endpoint, ...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings,
keys: {
p256dh: pushSubscription.getKey("p256dh"),
auth: pushSubscription.getKey("auth"),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
}; };
console.log( console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject);
pushSubscription.endpoint,
pushSubscription, const response = await fetch('/push.json', {
subscriptionObject, method: 'POST', headers: {
); 'Content-Type': 'application/json',
// The push subscription details needed by the application }, body: JSON.stringify(subscriptionObject),
// server are now available, and can be sent to it using, })
// for example, the fetch() API.
}, if (!response.ok) {
(error) => { throw new Error('Bad status code from server.');
console.error(error); }
},
); 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);

View File

@ -1,65 +1,80 @@
async function requestNotificationPermission() { function isClientOpen(url) {
const permission = await Notification.requestPermission(); return clients.matchAll().then((matchedClients) => {
return permission === "granted"; return matchedClients.some((matchedClient) => {
} return matchedClient.url === url && "focus" in matchedClient;
// 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) => { 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")) { self.addEventListener("push", async (event) => {
if (!self.Notification || self.Notification.permission !== "granted") {
console.log("Notification permission not granted");
return; return;
} }
console.log("Received a push message", event);
const data = event.data?.json() ?? {}; const data = event.data?.json() ?? {};
console.log("Received a push message", event, data);
if(await isClientOpen(data.url || data.link || `/web.html`)){
console.log("Client already open, not showing notification.");
return;
}
const title = data.title || "Something Has Happened"; const title = data.title || "Something Has Happened";
const message = const message =
data.message || "Here's something you might want to check out."; data.message || "Here's something you might want to check out.";
const icon = "images/new-notification.png"; const icon = data.icon || "/image/snek512.png";
event.waitUntil(self.registration.showNotification(title, { const notificationSettings = data.notificationSettings || {};
console.log("Showing message", title, message, icon);
const reg = self.registration.showNotification(title, {
body: message, body: message,
tag: "simple-push-demo-notification", tag: "message-received",
icon, icon,
})); badge: icon,
...notificationSettings,
data,
}).then(e => console.log("Showing notification", e)).catch(console.error);
event.waitUntil(reg);
}); });
self.addEventListener("notificationclick", (event) => { self.addEventListener("notificationclick", (event) => {
console.log("Notification click Received.", event); console.log("Notification click Received.", event);
event.notification.close(); event.notification.close();
event.waitUntil(clients.openWindow( event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`));
"https://snek.molodetz.nl",)); });
});*/
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

@ -142,10 +142,9 @@ export class Socket extends EventHandler {
method, method,
args, args,
}; };
const me = this;
return new Promise((resolve) => { return new Promise((resolve) => {
me.addEventListener(call.callId, (data) => resolve(data)); this.addEventListener(call.callId, (data) => resolve(data), { once: true});
me.sendJson(call); this.sendJson(call);
}); });
} }
} }

181
src/snek/static/stt.js Normal file
View File

@ -0,0 +1,181 @@
class STTButton extends HTMLElement {
static get observedAttributes() { return ['target']; }
simulateTypingWithEvents(element, text, delay = 100) {
let resolver = null;
const promise = new Promise((resolve) => resolver = resolve);
let index = 0;
const triggerEvent = (type, key) => {
const event = new KeyboardEvent(type, {
key: key,
bubbles: true,
cancelable: true,
});
element.dispatchEvent(event);
};
const interval = setInterval(() => {
if (index < text.length) {
const char = text.charAt(index);
triggerEvent('keydown', char);
if (element.isContentEditable) {
document.execCommand('insertText', false, char);
} else {
element.value += char;
}
triggerEvent('keypress', char);
triggerEvent('keyup', char);
index++;
} else {
clearInterval(interval);
resolver();
}
}, delay);
return promise;
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
const btn = document.createElement('button');
btn.setAttribute('part', 'button');
btn.setAttribute('aria-label', 'Start voice dictation');
btn.innerHTML = '🎤';
const style = document.createElement('style');
style.textContent = `
:host { display:inline-block; }
button { all:unset; cursor:pointer; }
button:hover { background:#e8e8e8; }
:host([listening]) button {
background:#d32f2f; color:#fff;
animation:pulse 1.2s ease-in-out infinite; }
@keyframes pulse {
0%,100% { transform:scale(1); box-shadow:0 0 0 0 rgba(211,47,47,.6);}
50% { transform:scale(1.12);box-shadow:0 0 0 12px rgba(211,47,47,0);}
}
`;
this.shadowRoot.append(style, btn);
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
console.warn('Web Speech API not supported in this browser.');
return;
}
this.recog = new SR();
this.recog.lang = 'en-US';
this.recog.continuous = true;
this.recog.interimResults = true;
let interim = '';
let committed = '';
let previousInterim = '';
this.recog.onresult = (e) => {
interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const res = e.results[i];
const alt = res[0];
const txt = alt.transcript.trim();
if (res.isFinal) {
const sentence = txt.charAt(0).toUpperCase() + txt.slice(1);
let punctuated = /[.!?]$/.test(sentence) ? sentence : sentence + '.';
committed += punctuated + ' ';
if (this.targetEl) {
this.targetEl.focus();
punctuated = punctuated.replace(/\./g, ".\n")
.replace(/\?/g, "?\n")
.replace(/\!/g, "!\n");
this.targetEl.value = punctuated; // punctuated;
this.simulateTypingWithEvents(this.targetEl, ' ', 0).then(() => {
const chatInput = document.querySelector('chat-input');
chatInput.finalizeMessage();
});
}
previousInterim = '';
} else {
if (alt.confidence >= 0.85) {
interim += txt + ' ';
}
}
}
if (interim && this.targetEl) {
const el = this.targetEl;
el.focus();
if (el.isContentEditable) {
el.innerText = el.innerText.replace(new RegExp(previousInterim + '$'), '');
} else {
el.value = interim
// el.value = interim
//el.value = el.value.replace(new RegExp(previousInterim + '$'), interim);
}
document.querySelector('chat-input').sendMessage(interim);
this.simulateTypingWithEvents(el, ' ', 0).then(() => {
})
// this.simulateTypingWithEvents(el, interim, 0).then(() => {
// previousInterim = interim;
// });
}
};
this.recog.onend = () => {
if (this.listening) {
try {
this.recog.start();
} catch (e) {
console.warn('Failed to restart speech recognition:', e);
}
}
};
btn.addEventListener('click', () => this.toggle());
}
attributeChangedCallback(name, _, newVal) {}
get targetEl() {
return document.querySelector("textarea") || null;
}
connectedCallback() {}
get listening() {
return this.hasAttribute('listening');
}
set listening(val) {
if (val) this.setAttribute('listening', '');
else this.removeAttribute('listening');
}
toggle() {
if (!this.recog) return;
if (this.listening) {
this.recog.stop();
this.listening = false;
} else {
try {
this.recog.start();
this.listening = true;
} catch (e) {
console.warn('Error starting recognition:', e);
}
}
}
}
customElements.define('stt-button', STTButton);

69
src/snek/static/tts.js Normal file
View File

@ -0,0 +1,69 @@
class SnekSpeaker extends HTMLElement {
_enabled = false
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Optionally show something in the DOM
this.shadowRoot.innerHTML = `<slot></slot>`;
this._utterance = new SpeechSynthesisUtterance();
this._selectVoice();
}
toggle() {
if (window.speechSynthesis.speaking) {
window.speechSynthesis.pause();
} else {
window.speechSynthesis.resume();
}
}
stop() {
window.speechSynthesis.cancel();
}
disable() {
this._enabled = false
}
enable() {
this._enabled = true
}
set enabled(val) {
if (val) {
this.enable()
} else {
this.disable()
}
}
get enabled() {
return this._enabled
}
_selectVoice() {
const updateVoice = () => {
const voices = window.speechSynthesis.getVoices();
const maleEnglishVoices = voices.filter(voice =>
voice.lang.startsWith('en') && voice.name.toLowerCase().includes('male')
);
if (maleEnglishVoices.length > 0) {
this._utterance.voice = maleEnglishVoices[0];
}
};
updateVoice();
// Some browsers load voices asynchronously
window.speechSynthesis.onvoiceschanged = updateVoice;
}
speak(text) {
if(!this._enabled) return
if (!text) return;
this._utterance.text = text;
window.speechSynthesis.speak(this._utterance);
}
}
// Define the element
customElements.define('snek-speaker', SnekSpeaker);

View File

@ -112,9 +112,9 @@ class UploadButtonElement extends HTMLElement {
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button"); this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input"); this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener("click", () => { /*this.uploadButton.addEventListener("click", () => {
this.fileInput.click(); this.fileInput.click();
}); });*/
this.fileInput.addEventListener("change", () => { this.fileInput.addEventListener("change", () => {
this.uploadFiles(); this.uploadFiles();
}); });

View File

@ -14,7 +14,7 @@ class Cache:
self.cache = {} self.cache = {}
self.max_items = max_items self.max_items = max_items
self.stats = {} self.stats = {}
self.enabled = False self.enabled = True
self.lru = [] self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4 self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4

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

@ -0,0 +1,233 @@
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
async def shutdown(self):
print("Stopping all sessions")
tasks = []
for name in self.list_instances():
proc = self.running_instances.get(name)
if not proc:
continue
if proc['proc'].returncode == None:
print("Stopping",name)
tasks.append(asyncio.create_task(proc['proc'].stop()))
print("Stopped",name,"gracefully")
return tasks
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 = {
"build": {
"context": ".",
"dockerfile": "DockerfileUbuntu",
},
"user":"root",
"working_dir": "/home/retoor/projects/snek",
"environment": [f"SNEK_UID={name}"],
}
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 = None, None
while proc.returncode is None:
try:
stdout, stderr = await proc.communicate()
except Exception as ex:
stdout = b''
stderr = str(ex).encode()
await self.event_handler(name,"stdout",stdout or stderr)
print("Return code", proc.returncode)
if stdout:
await self.event_handler(name,"stdout",stdout or b'')
return stdout and stdout.decode(errors="ignore") or ""
await self.event_handler(name,"stdout",stderr or b'')
return stderr and stderr.decode(errors="ignore") or ""
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",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout, stderr = None, None
while proc.returncode is None:
try:
stdout, stderr = await proc.communicate()
except Exception as ex:
stdout = b''
stderr = str(ex).encode()
if stdout:
print(stdout.decode(errors="ignore"))
if stderr:
print(stderr.decode(errors="ignore"))
await self.event_handler(name,"stdout",stdout or stderr)
print("Return code", proc.returncode)
master, slave = pty.openpty()
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "exec", name, "/usr/local/bin/entry",
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

View File

@ -1,6 +1,7 @@
DEFAULT_LIMIT = 30 DEFAULT_LIMIT = 30
import typing
import asyncio import asyncio
import typing
from snek.system.model import BaseModel from snek.system.model import BaseModel
@ -9,6 +10,7 @@ class BaseMapper:
model_class: BaseModel = None model_class: BaseModel = None
default_limit: int = DEFAULT_LIMIT default_limit: int = DEFAULT_LIMIT
table_name: str = None table_name: str = None
semaphore = asyncio.Semaphore(1)
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
@ -24,7 +26,18 @@ class BaseMapper:
return asyncio.get_event_loop() return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs): async def run_in_executor(self, func, *args, **kwargs):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs)) use_semaphore = kwargs.pop("use_semaphore", False)
def _execute():
result = func(*args, **kwargs)
if use_semaphore:
self.db.commit()
return result
return _execute()
#async with self.semaphore:
# return await self.loop.run_in_executor(None, _execute)
async def new(self): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@ -36,8 +49,9 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel: async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid: if uid:
kwargs["uid"] = uid kwargs["uid"] = uid
if not kwargs.get("deleted_at"):
record = await self.run_in_executor(self.table.find_one,**kwargs) kwargs["deleted_at"] = None
record = await self.run_in_executor(self.table.find_one, **kwargs)
if not record: if not record:
return None return None
record = dict(record) record = dict(record)
@ -45,10 +59,9 @@ class BaseMapper:
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
return model return model
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs): async def exists(self, **kwargs):
return await self.run_in_executor(self.table.exists,**kwargs) return await self.run_in_executor(self.table.exists, **kwargs)
async def count(self, **kwargs) -> int: async def count(self, **kwargs) -> int:
return await self.run_in_executor(self.table.count, **kwargs) return await self.run_in_executor(self.table.count, **kwargs)
@ -57,22 +70,39 @@ class BaseMapper:
if not model.record.get("uid"): if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.") raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update() model.updated_at.update()
return await self.run_in_executor(self.table.upsert, model.record, ["uid"]) return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
async def find(self, **kwargs) -> typing.AsyncGenerator: async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit kwargs["_limit"] = self.default_limit
if not kwargs.get("deleted_at"):
kwargs["deleted_at"] = None
for record in await self.run_in_executor(self.table.find, **kwargs): for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new() model = await self.new()
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
yield model yield model
async def _use_semaphore(self, sql):
sql = sql.lower().strip()
return "insert" in sql or "update" in sql or "delete" in sql
async def query(self, sql, *args): async def query(self, sql, *args):
for record in await self.run_in_executor(self.db.query,sql, *args): for record in await self.run_in_executor(self.db.query, sql, *args, use_semaphore=await self._use_semaphore(sql)):
yield dict(record) yield dict(record)
async def update(self, model):
if not model["deleted_at"] is None:
raise Exception("Can't update deleted record.")
model.updated_at.update()
return await self.run_in_executor(self.table.update, model.record, ["uid"],use_semaphore=True)
async def upsert(self, model):
model.updated_at.update()
return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
async def delete(self, **kwargs) -> int: async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict): if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.") raise Exception("Can't execute delete with no filter.")
kwargs["use_semaphore"] = True
return await self.run_in_executor(self.table.delete, **kwargs) return await self.run_in_executor(self.table.delete, **kwargs)

View File

@ -1,8 +1,8 @@
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2 # Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
import re
from types import SimpleNamespace from types import SimpleNamespace
from app.cache import time_cache_async from app.cache import time_cache_async, time_cache
from mistune import HTMLRenderer, Markdown from mistune import HTMLRenderer, Markdown
from mistune.plugins.formatting import strikethrough from mistune.plugins.formatting import strikethrough
from mistune.plugins.spoiler import spoiler from mistune.plugins.spoiler import spoiler
@ -12,9 +12,25 @@ from pygments.formatters import html
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
def strip_markdown(md_text):
# Remove code blocks (
md_text = re.sub(r"[\s\S]?```", "", md_text)
md_text = re.sub(r"^\s{4,}.$", "", md_text, flags=re.MULTILINE)
md_text = re.sub(r"^\s{0,3}#{1,6}\s+", "", md_text, flags=re.MULTILINE)
md_text = re.sub(r"!\[.?\]\(.?\)", "", md_text)
md_text = re.sub(r"\[([^\]]+)\]\(.?\)", r"\1", md_text)
md_text = re.sub(r"(\*|_){1,3}(.+?)\1{1,3}", r"\2", md_text)
md_text = re.sub(r"^\s{0,3}>+\s?", "", md_text, flags=re.MULTILINE)
md_text = re.sub(r"^(\s)(\-{3,}|_{3,}|\{3,})\s$", "", md_text, flags=re.MULTILINE)
md_text = re.sub(r"[`~>#+\-=]", "", md_text)
md_text = re.sub(r"\s+", " ", md_text)
return md_text.strip()
class MarkdownRenderer(HTMLRenderer): class MarkdownRenderer(HTMLRenderer):
_allow_harmful_protocols = True _allow_harmful_protocols = False
def __init__(self, app, template): def __init__(self, app, template):
super().__init__(False, True) super().__init__(False, True)
@ -26,8 +42,8 @@ class MarkdownRenderer(HTMLRenderer):
formatter = html.HtmlFormatter() formatter = html.HtmlFormatter()
self.env.globals["highlight_styles"] = formatter.get_style_defs() self.env.globals["highlight_styles"] = formatter.get_style_defs()
def _escape(self, str): # def _escape(self, str):
return str ##escape(str) # return str ##escape(str)
def get_lexer(self, lang, default="bash"): def get_lexer(self, lang, default="bash"):
try: try:
@ -35,6 +51,7 @@ class MarkdownRenderer(HTMLRenderer):
except: except:
return get_lexer_by_name(default, stripall=True) return get_lexer_by_name(default, stripall=True)
@time_cache(timeout=60 * 60)
def block_code(self, code, lang=None, info=None): def block_code(self, code, lang=None, info=None):
if not lang: if not lang:
lang = info lang = info

View File

@ -1,21 +1,42 @@
# Written by retoor@molodetz.nl # Written by retoor@molodetz.nl
# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers. # This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers.
# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library.
# MIT License: This code is distributed under the MIT License.
import secrets
from aiohttp import web from aiohttp import web
@web.middleware
async def csp_middleware(request, handler):
nonce = secrets.token_hex(16)
origin = request.headers.get('Origin')
csp_policy = (
"default-src 'self'; "
f"script-src 'self' {origin} 'nonce-{nonce}'; "
f"style-src 'self' 'unsafe-inline' {origin} 'nonce-{nonce}'; "
"img-src *; "
"connect-src 'self' https://umami.molodetz.nl; "
"font-src *; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-src 'self'; "
"worker-src *; "
"media-src *; "
"manifest-src 'self';"
)
request['csp_nonce'] = nonce
response = await handler(request)
#response.headers['Content-Security-Policy'] = csp_policy
return response
@web.middleware @web.middleware
async def no_cors_middleware(request, handler): async def no_cors_middleware(request, handler):
response = await handler(request) response = await handler(request)
response.headers.pop("Access-Control-Allow-Origin", None) response.headers.pop("Access-Control-Allow-Origin", None)
return response return response
@web.middleware @web.middleware
async def cors_allow_middleware(request, handler): async def cors_allow_middleware(request, handler):
response = await handler(request) response = await handler(request)
@ -27,7 +48,6 @@ async def cors_allow_middleware(request, handler):
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
return response return response
@web.middleware @web.middleware
async def auth_middleware(request, handler): async def auth_middleware(request, handler):
request["user"] = None request["user"] = None
@ -37,7 +57,6 @@ async def auth_middleware(request, handler):
) )
return await handler(request) return await handler(request)
@web.middleware @web.middleware
async def cors_middleware(request, handler): async def cors_middleware(request, handler):
if request.headers.get("Allow"): if request.headers.get("Allow"):
@ -50,4 +69,5 @@ async def cors_middleware(request, handler):
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*" response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
return response return response

View File

@ -255,6 +255,9 @@ class BaseModel:
def mapper(self): def mapper(self):
return self._mapper return self._mapper
def save(self):
return self.mapper.save(self)
@mapper.setter @mapper.setter
def mapper(self, value): def mapper(self, value):
self._mapper = value self._mapper = value

View File

@ -63,9 +63,11 @@ def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:
obj = hashlib.sha256(salted) obj = hashlib.sha256(salted)
return obj.hexdigest() return obj.hexdigest()
async def hash(data: str, salt: str = DEFAULT_SALT) -> str: async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
return hash_sync(data, salt) return hash_sync(data, salt)
def verify_sync(string: str, hashed: str) -> bool: def verify_sync(string: str, hashed: str) -> bool:
"""Verify if the given string matches the hashed value. """Verify if the given string matches the hashed value.
@ -78,5 +80,6 @@ def verify_sync(string: str, hashed: str) -> bool:
""" """
return hash_sync(string) == hashed return hash_sync(string) == hashed
async def verify(string: str, hashed: str) -> bool: async def verify(string: str, hashed: str) -> bool:
return verify_sync(string, hashed) return verify_sync(string, hashed)

View File

@ -26,6 +26,9 @@ class BaseService:
kwargs["uid"] = uid kwargs["uid"] = uid
return await self.count(**kwargs) > 0 return await self.count(**kwargs) > 0
async def update(self, model):
return await self.mapper.update(model)
async def count(self, **kwargs): async def count(self, **kwargs):
return await self.mapper.count(**kwargs) return await self.mapper.count(**kwargs)
@ -36,11 +39,15 @@ class BaseService:
for record in self.app.db.query(sql, *args): for record in self.app.db.query(sql, *args):
yield record yield record
async def get(self, uid=None, **kwargs): async def get(self, *args, **kwargs):
if uid: if not "deleted_at" in kwargs:
if not kwargs: kwargs["deleted_at"] = None
uid = kwargs.get("uid")
if args:
uid = args[0]
if uid or "uid" in kwargs:
result = await self.cache.get(uid) result = await self.cache.get(uid)
if False and result and result.__class__ == self.mapper.model_class: if result and result.__class__ == self.mapper.model_class:
return result return result
kwargs["uid"] = uid kwargs["uid"] = uid
@ -49,7 +56,7 @@ class BaseService:
await self.cache.set(result["uid"], result) await self.cache.set(result["uid"], result)
return result return result
async def save(self, model: UserModel): async def save(self, model):
# if model.is_valid: You Know why not # if model.is_valid: You Know why not
if await self.mapper.save(model): if await self.mapper.save(model):
await self.cache.set(model["uid"], model) await self.cache.set(model["uid"], model)

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

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

View File

@ -1,11 +1,12 @@
import mimetypes import mimetypes
import re import re
from functools import lru_cache
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs from urllib.parse import parse_qs, urlparse
from app.cache import time_cache
import bleach
import emoji import emoji
import requests import requests
from app.cache import time_cache
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
@ -78,6 +79,42 @@ emoji.EMOJI_DATA[
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]} ] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
def sanitize_html(value):
soup = BeautifulSoup(value, 'html.parser')
for script in soup.find_all('script'):
script.decompose()
#for iframe in soup.find_all('iframe'):
#iframe.decompose()
for tag in soup.find_all(['object', 'embed']):
tag.decompose()
for tag in soup.find_all():
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']
for attr in event_attributes:
if attr in tag.attrs:
del tag[attr]
for img in soup.find_all('img'):
if 'onerror' in img.attrs:
img.decompose()
return soup.prettify()
def sanitize_html2(value):
return bleach.clean(
value,
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
strip=True,
)
def set_link_target_blank(text): def set_link_target_blank(text):
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")
@ -89,6 +126,9 @@ def set_link_target_blank(text):
return str(soup) return str(soup)
def whitelist_attributes(html):
return sanitize_html(html)
def embed_youtube(text): def embed_youtube(text):
soup = BeautifulSoup(text, "html.parser") soup = BeautifulSoup(text, "html.parser")
@ -100,11 +140,12 @@ def embed_youtube(text):
or url.hostname or url.hostname
in [ in [
"www.youtube.com", "www.youtube.com",
"music.youtube.com",
"youtube.com", "youtube.com",
"www.youtube-nocookie.com", "www.youtube-nocookie.com",
"youtube-nocookie.com", "youtube-nocookie.com",
] ]
and any(url.path.startswith(p) for p in ["/watch", "/embed"]) and any(url.path.startswith(p) for p in ["/watch", "/embed", "/shorts"])
): ):
queries = parse_qs(url.query) queries = parse_qs(url.query)
if "v" in queries: if "v" in queries:
@ -184,12 +225,12 @@ def enrich_image_rendering(text):
for element in soup.find_all("img"): for element in soup.find_all("img"):
if element.attrs["src"].startswith("/"): if element.attrs["src"].startswith("/"):
element.attrs["src"] += "?width=240&height=240" element.attrs["src"] += "?width=240&height=240"
picture_template = f''' picture_template = f"""
<picture> <picture>
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" /> <source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
<source srcset="{element.attrs["src"]}&format=webp" type="image/webp" /> <source srcset="{element.attrs["src"]}&format=webp" type="image/webp" />
<img src="{element.attrs["src"]}&format=png" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" /> <img src="{element.attrs["src"]}&format=png" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
</picture>''' </picture>"""
element.replace_with(BeautifulSoup(picture_template, "html.parser")) element.replace_with(BeautifulSoup(picture_template, "html.parser"))
return str(soup) return str(soup)
@ -233,7 +274,7 @@ def linkify_https(text):
return set_link_target_blank(str(soup)) return set_link_target_blank(str(soup))
@time_cache(timeout=60*60) @time_cache(timeout=60 * 60)
def get_url_content(url): def get_url_content(url):
try: try:
response = requests.get(url, timeout=5) response = requests.get(url, timeout=5)
@ -244,29 +285,11 @@ def get_url_content(url):
return None return None
def embed_url(text): def get_element_options(head_info, elem=None, meta=None, ograph=None, twitter=None):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if page:
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if head_info:
def get_element_options(
elem=None, meta=None, ograph=None, twitter=None
):
if twitter: if twitter:
tw_tag = head_info.find( tw_tag = head_info.find(
"meta", attrs={"name": "twitter:" + twitter} "meta", attrs={"name": "twitter:" + twitter}
) or head_info.find( ) or head_info.find("meta", attrs={"property": "twitter:" + twitter})
"meta", attrs={"property": "twitter:" + twitter}
)
if tw_tag: if tw_tag:
return tw_tag.get("content", tw_tag.get("value", None)) return tw_tag.get("content", tw_tag.get("value", None))
@ -278,13 +301,11 @@ def embed_url(text):
return og_tag.get("content", og_tag.get("value", None)) return og_tag.get("content", og_tag.get("value", None))
if meta: if meta:
meta_tag = head_info.find( meta_tag = head_info.find("meta", attrs={"name": meta}) or head_info.find(
"meta", attrs={"name": meta} "meta", attrs={"property": meta}
) or head_info.find("meta", attrs={"property": meta})
if meta_tag:
return meta_tag.get(
"content", meta_tag.get("value", None)
) )
if meta_tag:
return meta_tag.get("content", meta_tag.get("value", None))
if elem: if elem:
elem_tag = head_info.find(elem) elem_tag = head_info.find(elem)
@ -293,34 +314,110 @@ def embed_url(text):
return None return None
def embed_url(text):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if (
"href" in element.attrs
and element.attrs["href"].startswith("http")
and element.attrs["href"] not in attachments
and ("data-noembed" not in element.attrs)
):
original_link_name = element.attrs["href"] original_link_name = element.attrs["href"]
if original_link_name in attachments: page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if not page:
continue
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if not head_info:
continue continue
page_name = ( page_name = (
get_element_options("title", "title", "title", "title") get_element_options(head_info, "title", "title", "title", "title")
or page_url.netloc or page_url.netloc
) )
page_site = ( page_site = (
get_element_options(None, "site", "site", "site") get_element_options(head_info, None, "site", "site", "site")
or get_element_options(head_info, ograph="site_name")
or page_url.netloc or page_url.netloc
) )
page_description = get_element_options( page_description = get_element_options(
None, "description", "description", "description" head_info, None, "description", "description", "description"
)
page_image = (
get_element_options(
head_info,
None,
"image:secure_url",
"image:secure_url",
"image:secure_url",
)
or get_element_options(
head_info, None, "image:url", "image:url", "image:url"
)
or get_element_options(head_info, None, "image", "image", "image")
)
page_image_height = get_element_options(
head_info, None, "image:height", "image:height", "image:height"
)
page_image_width = get_element_options(
head_info, None, "image:width", "image:width", "image:width"
) )
page_image = get_element_options(None, "image", "image", "image")
page_image_alt = get_element_options( page_image_alt = get_element_options(
None, "image:alt", "image:alt", "image:alt" head_info, None, "image:alt", "image:alt", "image:alt"
)
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
get_element_options(None, None, None, "card")
or "summary_large_image"
) )
page_video = (
get_element_options(
head_info,
None,
"video:secure_url",
"video:secure_url",
"video:secure_url",
)
or get_element_options(
head_info, None, "video:url", "video:url", "video:url"
)
or get_element_options(head_info, None, "video", "video", "video")
)
page_video_height = get_element_options(
head_info, None, "video:height", "video:height", "video:height"
)
page_video_width = get_element_options(
head_info, None, "video:width", "video:width", "video:width"
)
page_video_type = get_element_options(
head_info, None, "video:type", "video:type", "video:type"
)
page_audio = (
get_element_options(
head_info,
None,
"audio:secure_url",
"audio:secure_url",
"audio:secure_url",
)
or get_element_options(
head_info, None, "audio:url", "audio:url", "audio:url"
)
or get_element_options(head_info, None, "audio", "audio", "audio")
)
page_player = get_element_options(head_info, twitter="player")
page_player_width = get_element_options(head_info, twitter="player:width")
page_player_height = get_element_options(head_info, twitter="player:height")
(get_element_options(head_info, twitter="card") or "summary_large_image")
attachment_base = BeautifulSoup(str(element), "html.parser") attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base attachments[original_link_name] = attachment_base
@ -332,47 +429,61 @@ def embed_url(text):
render_element = attachment render_element = attachment
if page_image: if page_player:
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>' style = {
render_element.append( "width": page_player_width + "px" if page_player_width else None,
BeautifulSoup(image_template, "html.parser") "height": f"{(page_player_height or '400')}px",
}
style_string = "; ".join(
f"{key}: {value}" for key, value in style.items() if value
) )
if page_video: player_template = f'<iframe src="{page_player}" style="{style_string}" title="{page_name}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>' render_element.append(BeautifulSoup(player_template, "html.parser"))
render_element.append( elif page_video:
BeautifulSoup(video_template, "html.parser") style = {
"width": page_video_width + "px" if page_video_width else None,
"height": f"{(page_video_height or '400')}px",
}
style_string = "; ".join(
f"{key}: {value}" for key, value in style.items() if value
) )
if not page_video_type or page_video_type.startswith("video/"):
video_template = f'<video style="{style_string}" controls><source src="{page_video}" type="{page_video_type}">Your browser does not support the video tag.</video>'
else:
video_template = f'<iframe style="{style_string}" src="{page_video}" title="{page_name}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
render_element.append(BeautifulSoup(video_template, "html.parser"))
elif page_image:
style = {
"width": page_image_width + "px" if page_image_width else None,
"height": page_image_height + "px" if page_image_height else None,
}
style_string = "; ".join(
f"{key}: {value}" for key, value in style.items() if value
)
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="1" height="1" style="{style_string}" /></span>'
render_element.append(BeautifulSoup(image_template, "html.parser"))
if page_audio: if page_audio:
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>' audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
render_element.append( render_element.append(BeautifulSoup(audio_template, "html.parser"))
BeautifulSoup(audio_template, "html.parser")
)
description_element_base = BeautifulSoup( description_element = BeautifulSoup(
"<span class='description'></span>", "html.parser" f"""
) <span class='description'>
description_element = next(description_element_base.children) <p class="page-site">{page_site}</p>
description_element.append( <strong class="page-name">{page_name}</strong>
BeautifulSoup( <p class='page-description'>{page_description or "No description available."}</p>
f'<p class="page-site">{page_site}</p>', <p class='page-original-link'>{original_link_name}</p>
</span>
""",
"html.parser", "html.parser",
) )
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
)
render_element.append(description_element_base)
render_element.append(description_element)
for attachment in attachments.values(): for attachment in attachments.values():
soup.append(attachment) soup.append(attachment)

View File

@ -1,6 +1,6 @@
class WebSocketClient: class WebSocketClient:
def __init__(self, hostname, port): def __init__(self, hostname, port):
self.buffer = b'' self.buffer = b""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.hostname = hostname self.hostname = hostname
self.port = port self.port = port
@ -9,14 +9,16 @@ class WebSocketClient:
def __getattr__(self, method, *args, **kwargs): def __getattr__(self, method, *args, **kwargs):
if method in self.__dict__.keys(): if method in self.__dict__.keys():
return self.__dict__[method] return self.__dict__[method]
def call(*args, **kwargs): def call(*args, **kwargs):
self.write(json.dumps({'method': method, 'args': args, 'kwargs': kwargs})) self.write(json.dumps({"method": method, "args": args, "kwargs": kwargs}))
return json.loads(self.read()) return json.loads(self.read())
return call return call
def connect(self): def connect(self):
self.socket.connect((self.hostname, self.port)) self.socket.connect((self.hostname, self.port))
key = base64.b64encode(b'1234123412341234').decode('utf-8') key = base64.b64encode(b"1234123412341234").decode("utf-8")
handshake = ( handshake = (
f"GET /db HTTP/1.1\r\n" f"GET /db HTTP/1.1\r\n"
f"Host: localhost:3131\r\n" f"Host: localhost:3131\r\n"
@ -25,28 +27,31 @@ class WebSocketClient:
f"Sec-WebSocket-Key: {key}\r\n" f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n" f"Sec-WebSocket-Version: 13\r\n\r\n"
) )
self.socket.sendall(handshake.encode('utf-8')) self.socket.sendall(handshake.encode("utf-8"))
response = self.read_until(b'\r\n\r\n') response = self.read_until(b"\r\n\r\n")
if b'101 Switching Protocols' not in response: if b"101 Switching Protocols" not in response:
raise Exception("Failed to connect to WebSocket") raise Exception("Failed to connect to WebSocket")
def write(self, message): def write(self, message):
message_bytes = message.encode('utf-8') message_bytes = message.encode("utf-8")
length = len(message_bytes) length = len(message_bytes)
if length <= 125: if length <= 125:
self.socket.sendall(b'\x81' + bytes([length]) + message_bytes) self.socket.sendall(b"\x81" + bytes([length]) + message_bytes)
elif length >= 126 and length <= 65535: elif length >= 126 and length <= 65535:
self.socket.sendall(b'\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes) self.socket.sendall(
b"\x81" + bytes([126]) + length.to_bytes(2, "big") + message_bytes
)
else: else:
self.socket.sendall(b'\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes) self.socket.sendall(
b"\x81" + bytes([127]) + length.to_bytes(8, "big") + message_bytes
)
def read_until(self, delimiter): def read_until(self, delimiter):
while True: while True:
find_pos = self.buffer.find(delimiter) find_pos = self.buffer.find(delimiter)
if find_pos != -1: if find_pos != -1:
data = self.buffer[:find_pos+4] data = self.buffer[: find_pos + 4]
self.buffer = self.buffer[find_pos+4:] self.buffer = self.buffer[find_pos + 4 :]
return data return data
chunk = self.socket.recv(1024) chunk = self.socket.recv(1024)
@ -60,7 +65,7 @@ class WebSocketClient:
if not chunk: if not chunk:
return None return None
self.buffer += chunk self.buffer += chunk
response = self.buffer[: length] response = self.buffer[:length]
self.buffer = self.buffer[length:] self.buffer = self.buffer[length:]
return response return response
@ -69,13 +74,11 @@ class WebSocketClient:
frame = self.read_exactly(2) frame = self.read_exactly(2)
length = frame[1] & 127 length = frame[1] & 127
if length == 126: if length == 126:
length = int.from_bytes(self.read_exactly(2), 'big') length = int.from_bytes(self.read_exactly(2), "big")
elif length == 127: elif length == 127:
length = int.from_bytes(self.read_exactly(8), 'big') length = int.from_bytes(self.read_exactly(8), "big")
message = self.read_exactly(length) message = self.read_exactly(length)
return message return message
def close(self): def close(self):
self.socket.close() self.socket.close()

View File

@ -1,10 +1,220 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>About Snek Web Community</title>
<meta name="description" content="Snek is a privacy-centric web community platform engineered for reliability, security, and professional-grade design." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 100vh;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 800px; margin: auto; padding: 2rem 0; }
.about-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.about-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
margin-bottom: .5rem;
}
.about-hero img {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
}
.section p, .section ul {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
margin-bottom: 2rem;
}
.topnav .container {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 1.05rem;
padding: 0.5rem 1rem;
border-radius: 4px;
margin-right: 0.35rem;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1.1rem;
font-weight: bold;
letter-spacing: 0.02em;
}
.topnav .home-link img {
width: 28px;
height: 28px;
margin: 0 0.1em 0 0;
vertical-align: middle;
border-radius: 5px;
background: #222;
}
@media (max-width: 480px) {
.about-hero h1 { font-size: 2rem; }
.about-hero img { width: 48px; height: 48px; }
.section { padding: 1rem; }
.topnav .container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.5rem 0.7rem;
font-size: 1rem;
margin-bottom: 0.15rem;
}
.topnav .home-link img {
width: 22px; height: 22px;
}
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0 1.3rem 0;
font-size: 1.01rem;
letter-spacing: 0.01em;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 480px) {
footer {
font-size: 0.97rem;
padding: 1rem 0 0.9rem 0;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek Home Logo" />
Snek Community
</a>
</div>
</nav>
<header class="container about-hero">
<img src="/image/snek_logo_256x256.png" alt="Snek Logo" />
<h1>About Snek</h1>
<p>Snek is engineered for professionals who demand reliability, uncompromising security, and mature, clean design. This is a platform for those who expect seamless functionality and consistent results.</p>
</header>
<main class="container">
<section class="section">
<h2>What is Snek?</h2>
<p>
Snek is a privacy-driven web community and collaboration platform built with rigorous attention to reliability and operational clarity. Security, stability, and ease of use are foundational—there is no tolerance for unnecessary complexity or distractions.
</p>
<p>
Onboarding is immediate: connect via WebDAV (<strong>davs://molodetz.online/webdav</strong>) or SFTP (<strong>sftp://molodetz.online:2242</strong>) using your Snek-issued credentials. Storage policies are governed by fair-use principles and subject to moderation to ensure operational integrity.
</p>
<p>
Repository management is centralized via user settings. Message editing is purposefully disabled by default to preserve communication fidelity and accountability.
</p>
<p>
The platform leverages <strong>aiohttp</strong> for high-performance asynchronous operations, coupled with a bespoke ORM ensuring data integrity and consistency. All validation is centralized for maximum efficiency. Deployment is streamlined—single-command provisioning and full Docker support are standard. <strong>SQLite</strong> is the default database for simplicity, with <strong>PostgreSQL</strong> available for advanced requirements.
</p>
<p>
The frontend employs vanilla JavaScript exclusively, guaranteeing a lightweight, high-speed user experience with minimal maintenance overhead. Key features include real-time typing indicators, integrated web bash terminal, Markdown support, and server-side rendering for optimal performance. Snek exists to address the growing disregard for privacy and trust in mainstream platforms—here, these are non-negotiable standards.
</p>
</section>
<section class="section">
<h2>Why does Snek exist?</h2>
<p>
Leading chat platforms routinely prioritize data exploitation, restrictive ecosystems, or inflated costs. Snek takes a disciplined, user-focused approach: privacy is uncompromised, the feature set is purposeful, and superfluous elements are eliminated.
</p>
<p>
Snek was established in direct response to industry trends—RocketChats commercial pivot, Slacks proprietary and expensive model, and Teams account lock-in and administrative burden. Snek is for those who require autonomy and technical transparency.
</p>
</section>
<section class="section">
<h2>Design Principles</h2>
<ul>
<li><strong>Privacy as Policy:</strong> No email required. No invasive logging. Expedited, secure registration. Your data remains exclusively yours.</li>
<li><strong>Open Source Commitment:</strong> Complete code transparency. Deploy your own Snek instance or contribute to the ecosystem.</li>
<li><strong>Engineering Rigor:</strong> No tolerance for bloat. All code is scrutinized for efficiency and security.</li>
<li><strong>Protocol Flexibility:</strong> Native compatibility with WebDAV, SFTP, and other open standards. Vendor lock-in is categorically rejected.</li>
<li><strong>Lean Architecture:</strong> Both backend and frontend are optimized for speed and resource efficiency.</li>
</ul>
</section>
<section class="section">
<h2>How is Snek Different?</h2>
<p>
Snek is a rigorously engineered, dependable platform designed for organizations and individuals who require transparency and predictability. There is no user tracking, analytics, or marketing—ever.
</p>
<p>
Feature additions are strictly merit-based: only those that deliver measurable value and meet high standards of reliability are integrated. The focus is on sustained, long-term utility.
</p>
</section>
<section class="section">
<h2>Mission Statement</h2>
<p>
Sneks mandate is to deliver robust, private communication and collaboration tools for professionals and discerning users. Our values: unwavering privacy, adherence to open standards, and uncompromising engineering discipline. Snek serves builders, technical leaders, and anyone seeking authoritative control over their digital environment.
</p>
<p>
We uphold transparency, foster direct and thoughtful dialogue, and maintain a strictly professional atmosphere. Clear communication and constructive collaboration are non-negotiable.
</p>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Engineered for business. Secure, resilient, and built for those who demand professional-grade software.</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
{% block main %}
<div class="dialog">
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
<html-frame url="/about.md"></html-frame>
</div>
{% endblock %}

View File

@ -6,37 +6,35 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Snek</title> <title>Snek</title>
<style>{{highlight_styles}}</style> <style>{{highlight_styles}}</style>
<link rel="stylesheet" href="/file-upload-grid.css">
<script src="/njet.js" type="module"></script>
<script src="/tts.js" type="module"></script>
<script src="/stt.js" type="module"></script>
<script src="/file-upload-grid.js" type="module"></script>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script> <script src="/polyfills/Promise.withResolvers.js" type="module"></script>
<!--
<script src="/push.js"></script> <script src="/push.js" type="module"></script>
-->
<script src="/fancy-button.js" type="module"></script> <script src="/fancy-button.js" type="module"></script>
<script src="/upload-button.js" type="module"></script> <script src="/upload-button.js" type="module"></script>
<script src="/generic-form.js" type="module"></script> <script src="/generic-form.js" type="module"></script>
<script src="/html-frame.js" type="module"></script> <script src="/html-frame.js" type="module"></script>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>
<script src="/editor.js" type="module"></script>
<script src="/file-manager.js" type="module"></script> <script src="/file-manager.js" type="module"></script>
<script src="/user-list.js"></script> <script src="/user-list.js"></script>
<script src="/message-list.js" type="module"></script> <script src="/message-list.js" type="module"></script>
<script src="/chat-input.js" type="module"></script> <script src="/chat-input.js" type="module"></script>
<script src="/container.js" type="module"></script>
<script src="/dumb-term.js" type="module"></script>
<link rel="stylesheet" href="/sandbox.css"> <link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css"> <link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/fa640.min.css">
<link rel="stylesheet" href="/base.css"> <link rel="stylesheet" href="/base.css">
<link <link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
rel="stylesheet" <link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" <script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
integrity="sha512-pBMV+3tn6+5xAZuhI6tyCmQkXh15riZDqGPxAx/U+FuiI5Dh3ZTjM23cZqQ25jJCfi8+ka9gzC2ukNkGkP/Aw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="icon" type="image/png" href="/image/snek1.png" sizes="32x32">
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head> </head>
<body> <body>
<header> <header>
<div class="logo no-select">{% block header_text %}{% endblock %}</div> <div class="logo no-select">{% block header_text %}{% endblock %}</div>
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth"> <nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
@ -46,6 +44,7 @@
<a class="no-select" style="display:none" id="install-button" href="#">📥</a> <a class="no-select" style="display:none" id="install-button" href="#">📥</a>
<a class="no-select" href="/threads.html">👥</a> <a class="no-select" href="/threads.html">👥</a>
<a class="no-select" href="/settings/index.html">⚙️</a> <a class="no-select" href="/settings/index.html">⚙️</a>
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
<a class="no-select" href="/logout.html">🔒</a> <a class="no-select" href="/logout.html">🔒</a>
</nav> </nav>
@ -54,13 +53,102 @@
{% block sidebar %} {% block sidebar %}
{% include "sidebar_channels.html" %} {% include "sidebar_channels.html" %}
{% endblock %} {% endblock %}
<main> <main>
{% block main %} {% block main %}
<chat-window class="chat-area"></chat-window> <chat-window class="chat-area"></chat-window>
{% endblock %} {% endblock %}
</main> </main>
<script> <script type="module">
import { app } from "/app.js";
import { Container } from "/container.js";
let prevKey = null;
function toggleDevelopmentMode(){
const headerElement = document.querySelector('header');
headerElement.style.display = 'none';
const sidebarElement = document.querySelector('aside');
sidebarElement.style.position = 'fixed'
sidebarElement.style.width= '10%'
sidebarElement.style.top = '0px'
sidebarElement.style.left='0px'
sidebarElement.style.height='100%'
// sidebarElement.style.display = 'none';
const containerElement = document.querySelector('#terminal');
containerElement.style.position = 'fixed';
containerElement.style.width = '50%';
containerElement.style.height = '100%';
containerElement.style.left = '10%';
containerElement.style.top = '0px';
//window.container.resizeToPercentage(document.body,'50%','100%')
const messagesElement = document.querySelector('.chat-area');
messagesElement.style.position = 'fixed';
messagesElement.style.width = '40%';
messagesElement.style.height = '100%';
messagesElement.style.left = '60%';
messagesElement.style.top = '0px';
const messageList = document.querySelector('message-list')
messageList.scrollToBottom()
window.container.fit()
app.starField.renderWord("H4x0r 1337")
}
{% if channel %}
app.channelUid = '{{ channel.uid.value }}'
window.getContainer = async function (){
if(window.c) return window.c
window.c = new Container(app.channelUid,false)
window.c.start()
window.t = document.querySelector("#terminal")
window.t.classList.toggle("hidden")
window.c.render(window.t)
return window.c
}
{% endif %}
document.addEventListener("keydown", async(event) => {
if(prevKey == "Escape"){
document.querySelector("chat-input").querySelector("textarea").value = "";
}
if(prevKey == "Escape" && event.key == "t"){
app.starField.shuffleAll(5000)
}
if(event.key == "." && event.ctrlKey){
event.preventDefault();
if(!window.c)
{
window.getContainer()
}
if(window.c){
toggleDevelopmentMode()
//window.container.terminal.element.hidden = !window.container.terminal.element.hidden
}
}
prevKey = event.key
if(event.key == "," && event.ctrlKey){
event.preventDefault();
let textAreas = document.querySelectorAll("textarea")
textAreas.forEach(textArea => {
if(document.activeElement != textArea)
setTimeout(() => textArea.focus(), 10)
})
}
})
let installPrompt = null let installPrompt = null
window.addEventListener("beforeinstallprompt", (e) => { window.addEventListener("beforeinstallprompt", (e) => {
//e.preventDefault(); //e.preventDefault();
@ -72,7 +160,6 @@ let installPrompt = null
button.addEventListener("click", async ()=>{ button.addEventListener("click", async ()=>{
const result = await installPrompt.prompt() const result = await installPrompt.prompt()
console.info(result.outcome)
}) })

View File

@ -10,6 +10,7 @@
<meta name="keywords" content="snek, chat, molodetz"> <meta name="keywords" content="snek, chat, molodetz">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<link rel="stylesheet" href="/sandbox.css" />
<title>{% block title %}Snek chat by Molodetz{% endblock %}</title> <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script> <script src="/polyfills/Promise.withResolvers.js" type="module"></script>
@ -34,6 +35,7 @@
<main> <main>
{% block main %} {% block main %}
{% endblock %} {% endblock %}
{% include "sandbox.html" %}
</main> </main>
</body> </body>
</html> </html>

View File

@ -0,0 +1,40 @@
<script type="module">
import { njet } from "/njet.js"
app.channel = {}
app.channel.remove = function(channelUid){
const dialog = new njet.showDialog({
title: 'Upload in progress',
content: 'Please wait for the current upload to complete.',
primaryButton: {
text: 'OK',
handler: function () {
dialog.remove();
}
}
})
}
app.channel._fileManager = null
app.channel.toggleDrive = function(channelUid){
if(app.channel._fileManager){
app.channel._fileManager.remove()
app.channel._fileManager = null
document.querySelector('message-list').style.display = 'block'
return
}
app.channel._fileManager = document.createElement("file-manager")
app.channel._fileManager.style.padding = '10px'
app.channel._fileManager.setAttribute("url",`/channel/${app.channelUid}/drive.json`)
document.querySelector(".chat-area").insertBefore(app.channel._fileManager,document.querySelector(".chat-area").firstChild)
document.querySelector("message-list").style.display = 'none'
}
</script>

View File

@ -0,0 +1,185 @@
<script>
class ContainerDialogComponent extends HTMLElement {
constructor() {
super();
this._channelUid = null;
this._dialog = null;
this._closeButton = null;
this._startButton = null;
this._restartButton = null;
this._stopButton = null;
this._statusSpan = null;
this._actions = null;
this._containerList = null;
}
connectedCallback() {
this._channelUid = this.getAttribute('channel');
// Render template
this.innerHTML = `
<dialog class="container-dialog">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Container</h2></div>
<div class="dialog-content">
<table class="container-properties">
<tr>
<th>Name</th>
<td><span class="container-name"></span></td>
</tr>
<tr>
<th>Image</th>
<td><span class="container-image"></span></td>
</tr>
<tr>
<th>CPUs</th>
<td><span class="container-cpus"></span></td>
</tr>
<tr>
<th>Memory</th>
<td><span class="container-memory"></span></td>
</tr>
<tr>
<th>Status</th>
<td><span class="container-status"></span></td>
</tr>
</table>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-container-restart">Restart</button>
<button class="dialog-button secondary btn-container-stop">Stop</button>
<button class="dialog-button secondary btn-container-start">Start</button>
<button class="dialog-button primary btn-container-close">Close</button>
</div>
</div>
</div>
</dialog>
`;
this._dialog = this.querySelector('dialog');
this._closeButton = this.querySelector('.btn-container-close');
this._startButton = this.querySelector('.btn-container-start');
this._restartButton = this.querySelector('.btn-container-restart');
this._stopButton = this.querySelector('.btn-container-stop');
this._statusSpan = this.querySelector('.container-status');
this._imageSpan = this.querySelector('.container-image');
this._memorySpan = this.querySelector('.container-memory');
this._cpusSpan = this.querySelector('.container-cpus');
this._nameSpan = this.querySelector('.container-name');
this._actions = this.querySelector('.dialog-actions');
this._initEventListeners();
}
set channelUid(val) {
this._channelUid = val;
}
get channelUid() {
return this._channelUid;
}
_initEventListeners() {
if (this._closeButton) {
this._closeButton.addEventListener('click', () => this.close());
}
if (this._startButton) {
this._startButton.addEventListener('click', () => this.startContainer());
}
if (this._restartButton) {
this._restartButton.addEventListener('click', () => this.restartContainer());
}
if (this._stopButton) {
this._stopButton.addEventListener('click', () => this.stopContainer());
}
}
async openWithStatus() {
if (!this._channelUid) return;
const container = await (window.app?.rpc?.getContainer?.(this._channelUid) ?? Promise.resolve('unknown'));
this.updateContainerInfo(container);
}
updateContainerInfo(container) {
this.updateStatus(container['status']);
this.updateCpus(container['cpus']);
this.updateMemory(container['memory']);
this.updateImage(container['image']);
this.updateName(container['name']);
this._dialog.showModal();
this._closeButton.focus();
}
updateStatus(status) {
this._statusSpan.innerText = status;
if (status === 'running') {
this._stopButton.disabled = false;
this._startButton.disabled = true;
this._restartButton.disabled = false;
} else {
this._stopButton.disabled = true;
this._startButton.disabled = false;
this._restartButton.disabled = true;
}
}
updateCpus(cpus) {
if (this._cpusSpan) {
this._cpusSpan.innerText = cpus;
}
}
updateMemory(memory) {
if (this._memorySpan) {
this._memorySpan.innerText = memory;
}
}
updateImage(image) {
if (this._imageSpan) {
this._imageSpan.innerText = image;
}
}
updateName(name) {
if (this._nameSpan) {
this._nameSpan.innerText = name;
}
}
close() {
this._dialog.close();
}
// Placeholder for container operations
async startContainer() {
if (!this._channelUid) return;
if (window.app?.rpc?.startContainer) {
await window.app.rpc.startContainer(this._channelUid);
await this.openWithStatus();
}
}
async stopContainer() {
if (!this._channelUid) return;
if (window.app?.rpc?.stopContainer) {
await window.app.rpc.stopContainer(this._channelUid);
await this.openWithStatus();
}
}
async restartContainer() {
if (!this._channelUid) return;
if (window.app?.rpc?.restartContainer) {
await window.app.rpc.restartContainer(this._channelUid);
await this.openWithStatus();
}
}
}
customElements.define('container-dialog', ContainerDialogComponent);
</script>
<container-dialog id="container-dialog" channel="{{ channel.uid.value }}"></container-dialog>
<script>
const containerDialog = document.getElementById('container-dialog');
</script>

View File

@ -0,0 +1,910 @@
{% extends "app.html" %}
{% block title %}Forum{% endblock %}
{% block header_text %}<nav class="breadcrumb" id="breadcrumb">
<!-- Breadcrumb will be rendered here -->
</nav>{% endblock %}
{% block main %}
<style>
snek-forum, .container {
height: 100%;
width: 90%;
background: transparent !important;
}
</style>
<script>
// snek-forum.js - Forum Web Component (Tech-centric Dark Theme)
class SnekForum extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.ws = null;
this.currentView = 'forums';
this.currentForum = null;
this.currentThread = null;
this.currentPage = 1;
}
connectedCallback() {
this.render();
this.connectWebSocket();
this.loadForums();
}
disconnectedCallback() {
if (this.ws) {
this.ws.close();
}
}
connectWebSocket() {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/forum/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
setTimeout(() => this.connectWebSocket(), 3000);
};
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'post_created':
if (this.currentView === 'thread' && this.currentThread?.uid === data.data.thread_uid) {
this.addNewPost(data.data.post);
}
break;
case 'post_edited':
this.updatePost(data.data.post);
break;
case 'post_deleted':
this.removePost(data.data.post.uid);
break;
case 'post_liked':
case 'post_unliked':
this.updatePostLikes(data.data.post_uid);
break;
case 'thread_created':
if (this.currentView === 'forum' && this.currentForum?.uid === data.data.forum_uid) {
this.loadForum(this.currentForum.slug);
}
break;
}
}
subscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'subscribe',
type: type,
id: id
}));
}
}
unsubscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'unsubscribe',
type: type,
id: id
}));
}
}
async fetchAPI(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async loadForums() {
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
this.renderForums(data.forums);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forums:', error);
}
}
async loadForum(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/forums/${slug}?page=${this.currentPage}`);
this.currentView = 'forum';
this.currentForum = data.forum;
this.subscribe('forum', data.forum.uid);
this.renderForum(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forum:', error);
}
}
async loadThread(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/threads/${slug}?page=${this.currentPage}`);
this.currentView = 'thread';
this.currentThread = data.thread;
if (this.currentForum) {
this.unsubscribe('forum', this.currentForum.uid);
}
this.subscribe('thread', data.thread.uid);
this.renderThread(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading thread:', error);
}
}
async createThread(forumSlug, title, content) {
try {
const data = await this.fetchAPI(`/forum/api/forums/${forumSlug}/threads`, {
method: 'POST',
body: JSON.stringify({ title, content })
});
this.loadThread(data.thread.slug);
} catch (error) {
console.error('Error creating thread:', error);
}
}
async createPost(threadUid, content) {
try {
await this.fetchAPI(`/forum/api/threads/${threadUid}/posts`, {
method: 'POST',
body: JSON.stringify({ content })
});
// Post will be added via WebSocket
} catch (error) {
console.error('Error creating post:', error);
}
}
async toggleLike(postUid) {
try {
const data = await this.fetchAPI(`/forum/api/posts/${postUid}/like`, {
method: 'POST'
});
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
const likeBtn = postEl.querySelector('.like-button');
const likeCount = postEl.querySelector('.like-count');
likeBtn.classList.toggle('liked', data.is_liked);
likeCount.textContent = data.like_count;
likeBtn.querySelector('span').textContent = data.is_liked ? '❤️' : '🤍';
}
} catch (error) {
console.error('Error toggling like:', error);
}
}
render() {
const accent = "#00FFFF";
const accent_alt = "#FF6200";
const isCyan = true; // switch between cyan and orange here
// Generate subtle star background (canvas dots)
const bgSvg = encodeURIComponent(`
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<g>
${Array.from({length: 40}).map(() => {
const x = Math.random() * 100;
const y = Math.random() * 100;
const r = Math.random() * 1.4 + 0.2;
return `<circle cx="${x}vw" cy="${y}vh" r="${r}" fill="white" opacity="0.2" />`;
}).join('')}
</g>
</svg>
`);
this.shadowRoot.innerHTML = `
<style>
@import url('https://fonts.googleapis.com/css?family=Montserrat:400,700&display=swap');
:host {
display: block;
min-height: 100vh;
font-family: 'Montserrat', 'Poppins', 'Roboto', Arial, sans-serif;
background: transparent !important;
color: #fff;
}
.container {
width: 100%;
height: 100%;
background: transparent !important;
}
.header {
margin-bottom: 24px;
text-align: center;
}
.breadcrumb {
display: flex;
gap: 10px;
align-items: center;
font-size: 14pt;
justify-content: center;
color: ${accent};
letter-spacing: 0.1em;
}
.breadcrumb a {
color: #fff;
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.breadcrumb a:hover {
text-decoration: underline;
color: ${accent_alt};
}
.breadcrumb span {
color: #A9A9A9;
font-weight: 700;
}
.content {
min-height: 120px;
background: transparent !important;
}
/* Forums List */
.forums-list {
padding: 0;
margin: 0;
}
.forum-item {
display: flex;
align-items: center;
padding: 24px 18px;
cursor: pointer;
transition: background 0.12s;
background: transparent !important;
}
.forum-item:hover {
background: #23282D;
}
.forum-item:last-child { border-bottom: none; }
.forum-icon {
width: 56px;
height: 56px;
margin-right: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #202B2E;
border-radius: 10px;
font-size: 30px;
color: ${accent};
border: 1.5px solid ${accent};
box-shadow: 0 1px 4px 0 #00FFFF22;
}
.forum-info { flex: 1; }
.forum-name {
font-size: 22pt;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 3px;
color: #fff;
letter-spacing: 0.05em;
}
.forum-description {
font-size: 16pt;
color: #A9A9A9;
margin-bottom: 5px;
font-weight: 400;
}
.forum-stats {
font-size: 12pt;
color: #00FFFF;
opacity: 0.7;
font-weight: 500;
}
/* Threads List */
.thread-item {
display: flex;
padding: 18px 18px;
cursor: pointer;
transition: background 0.12s;
background: transparent !important;
}
.thread-item:hover {
background: #20262B;
}
.thread-item.pinned {
border-left: 5px solid ${accent};
}
.thread-info { flex: 1; }
.thread-title {
font-size: 18pt;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
color: #fff;
}
.thread-meta {
font-size: 12pt;
color: #A9A9A9;
margin-top: 3px;
}
.thread-stats {
text-align: right;
font-size: 11pt;
color: #A9A9A9;
min-width: 120px;
}
.badge {
font-size: 10pt;
padding: 2px 10px;
border-radius: 4px;
background: #232323;
color: ${accent};
font-weight: 700;
text-transform: uppercase;
margin-left: 4px;
}
.badge.pinned {
background: ${accent};
color: #181E22;
}
.badge.locked {
background: #fff;
color: #181E22;
border: 1px solid ${accent_alt};
}
/* Posts */
.posts-list { margin: 0; }
.post {
display: flex;
padding: 24px 18px;
background: transparent !important;
}
.post-author {
width: 120px;
margin-right: 30px;
text-align: center;
}
.author-avatar {
width: 62px;
height: 62px;
border-radius: 50%;
background: #232323;
margin: 0 auto 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 27pt;
font-weight: bold;
border: 2px solid ${accent};
box-shadow: 0 2px 12px 0 #00FFFF11;
}
.author-name {
font-weight: 700;
font-size: 13pt;
color: #fff;
letter-spacing: 0.03em;
}
.post-content { flex: 1; }
.post-header {
font-size: 12pt;
color: #A9A9A9;
margin-bottom: 10px;
}
.post-body {
line-height: 1.7;
font-size: 15pt;
color: #fff;
white-space: pre-wrap;
}
.post-footer {
display: flex;
gap: 24px;
margin-top: 18px;
padding-top: 14px;
}
.post-action {
font-size: 13pt;
color: #A9A9A9;
cursor: pointer;
display: flex;
align-items: center;
gap: 7px;
font-weight: 600;
letter-spacing: 0.03em;
transition: color 0.12s;
}
.post-action:hover, .post-action.liked {
color: ${accent};
}
.like-button.liked span:first-child {
color: ${accent_alt};
text-shadow: 0 2px 8px #FF620066;
}
/* Forms */
.form-group {
margin-bottom: 22px;
}
.form-group label {
display: block;
margin-bottom: 7px;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
font-size: 15pt;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 13px 16px;
font-size: 16pt;
font-family: inherit;
color: #fff;
background: #232323;
outline: none;
transition: border-color 0.16s;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: ${accent_alt};
}
.form-group textarea {
min-height: 130px;
resize: vertical;
}
.button {
padding: 12px 32px;
border: none;
border-radius: 10px;
font-size: 16pt;
font-weight: 700;
cursor: pointer;
background: ${accent};
color: #fff;
box-shadow: 0 2px 10px #00FFFF33;
text-transform: uppercase;
letter-spacing: 0.07em;
transition: background 0.12s, color 0.12s;
}
.button:hover {
background: ${accent_alt};
color: #fff;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-thread-button {
display: block;
margin: 0 auto 24px auto;
width: 250px;
}
.reply-form {
width: 33%;
padding: 32px 18px 18px 18px;
margin-top: 0;
}
.reply-form h3 {
text-align: center;
color: ${accent};
font-size: 22pt;
margin-bottom: 18px;
font-weight: 700;
text-transform: uppercase;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: #181E22;
border-radius: 14px;
padding: 36px 32px;
max-width: 520px;
width: 96vw;
max-height: 90vh;
box-shadow: 0 2px 32px 0 #000C;
color: #fff;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 24pt;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
}
.modal-close {
font-size: 32px;
cursor: pointer;
background: none;
border: none;
color: #A9A9A9;
transition: color 0.13s;
}
.modal-close:hover {
color: ${accent_alt};
}
.pagination {
display: flex;
justify-content: center;
gap: 15px;
padding: 24px 0 10px 0;
}
.page-button {
padding: 7px 18px;
border: 1.5px solid ${accent};
background: #232323;
cursor: pointer;
border-radius: 8px;
color: #fff;
font-size: 14pt;
font-weight: 700;
text-transform: uppercase;
transition: background 0.13s, color 0.13s;
}
.page-button:hover {
background: ${accent};
color: #181E22;
}
.page-button.active {
background: ${accent};
color: #181E22;
border-color: ${accent_alt};
}
/* Feature card style for content sections */
.feature-card {
background: #1A1A1A;
border-radius: 10px;
box-shadow: 0 1px 8px #0006;
border: 1px solid #232323;
padding: 28px 22px;
margin-bottom: 20px;
color: #fff;
}
.feature-card .title {
color: ${accent_alt};
font-size: 20pt;
font-weight: 700;
margin-bottom: 9px;
text-transform: uppercase;
}
/* Minimal snake icon for branding */
.snake-icon {
display: inline-block;
width: 42px;
height: 42px;
margin-bottom: -10px;
vertical-align: middle;
}
.snake-icon path {
stroke: ${accent};
stroke-width: 3;
fill: none;
}
</style>
<div class="container">
<div class="content" id="main-content">
<!-- Content will be rendered here -->
</div>
</div>
<div class="modal" id="new-thread-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">NEW THREAD</h2>
<button class="modal-close" type="button">&times;</button>
</div>
<form id="new-thread-form">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required minlength="5" maxlength="200" placeholder="Thread Title">
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content" required minlength="1" placeholder="Write your post..."></textarea>
</div>
<button type="submit" class="button">CREATE THREAD</button>
</form>
</div>
</div>
`;
// Breadcrumb render
this.updateBreadcrumb();
// Add event listeners
this.shadowRoot.addEventListener('click', (e) => {
// Breadcrumb navigation
if (e.target.closest('.breadcrumb a')) {
e.preventDefault();
const node = e.target.closest('.breadcrumb a');
const action = node.dataset.action;
if (action === "forums") {
this.loadForums();
} else if (action === "forum") {
this.loadForum(node.dataset.slug);
}
}
// Forum item
else if (e.target.closest('[data-forum-slug]')) {
this.loadForum(e.target.closest('[data-forum-slug]').dataset.forumSlug);
}
// Thread item
else if (e.target.closest('[data-thread-slug]')) {
this.loadThread(e.target.closest('[data-thread-slug]').dataset.threadSlug);
}
// Like button
else if (e.target.closest('.like-button')) {
this.toggleLike(e.target.closest('[data-post-uid]').dataset.postUid);
}
// New thread button
else if (e.target.closest('.new-thread-button')) {
this.shadowRoot.getElementById('new-thread-modal').classList.add('show');
}
// Modal close
else if (e.target.closest('.modal-close')) {
e.target.closest('.modal').classList.remove('show');
}
// Pagination buttons
else if (e.target.matches('.page-button[data-page]')) {
const page = parseInt(e.target.dataset.page, 10);
if (this.currentView === "forum") {
this.loadForum(this.currentForum.slug, page);
} else if (this.currentView === "thread") {
this.loadThread(this.currentThread.slug, page);
}
}
});
// Form submissions
this.shadowRoot.getElementById('new-thread-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createThread(this.currentForum.slug, formData.get('title'), formData.get('content'));
e.target.reset();
this.shadowRoot.getElementById('new-thread-modal').classList.remove('show');
});
}
updateBreadcrumb() {
return;
const crumb = [];
crumb.push(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
}
if (this.currentView === "thread" && this.currentThread && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<a href="#" data-action="forum" data-slug="${this.currentForum.slug}">${this.currentForum.name.toUpperCase()}</a>`);
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
}
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
}
renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="forums-list">
${forums.map(forum => `
<div class="forum-item" data-forum-slug="${forum.slug}">
<div class="forum-icon">${forum.icon || '📁'}</div>
<div class="forum-info">
<div class="forum-name">${forum.name}</div>
${forum.description ? `<div class="forum-description">${forum.description}</div>` : ''}
<div class="forum-stats">
${forum.thread_count} threads · ${forum.post_count} posts
</div>
</div>
</div>
`).join('')}
</div>
`;
}
renderForum(data) {
const { forum, threads } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div style="padding: 18px;">
<button class="button new-thread-button">NEW THREAD</button>
<div style="clear: both;"></div>
<div class="threads-list">
${threads.map(thread => `
<div class="thread-item ${thread.is_pinned ? 'pinned' : ''}" data-thread-slug="${thread.slug}">
<div class="thread-info">
<div class="thread-title">
${thread.title}
${thread.is_pinned ? '<span class="badge pinned">PINNED</span>' : ''}
${thread.is_locked ? '<span class="badge locked">LOCKED</span>' : ''}
</div>
<div class="thread-meta">
Started by <span style="color:#00FFFF;font-weight:700">${thread.author.nick}</span>
· ${this.formatDate(thread.created_at)}
</div>
</div>
<div class="thread-stats">
<div>${thread.post_count} replies</div>
<div>${thread.view_count} views</div>
${thread.last_post_author ? `
<div style="margin-top: 7px; font-size: 12pt;">
Last: <span style="color:#FF6200;">${thread.last_post_author.nick}</span><br>
${this.formatDate(thread.last_post_at)}
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
${data.hasMore ? `
<div class="pagination">
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
</div>
`;
}
renderThread(data) {
const { thread, forum, posts } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="posts-list">
${posts.map(post => `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
${post.edited_at ? `· Edited ${this.formatDate(post.edited_at)}` : ''}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button ${post.is_liked ? 'liked' : ''}">
<span>${post.is_liked ? '❤️' : '🤍'}</span>
<span class="like-count">${post.like_count}</span>
</span>
</div>
</div>
</div>
`).join('')}
</div>
${!thread.is_locked ? `
<div class="reply-form">
<h3>Reply</h3>
<form id="reply-form">
<div class="form-group">
<textarea name="content" placeholder="Write your reply..." required></textarea>
</div>
<button type="submit" class="button">POST REPLY</button>
</form>
</div>
` : `
<div class="reply-form">
<p style="text-align: center; color: #A9A9A9; font-size: 18pt;">This thread is locked.</p>
</div>
`}
${data.hasMore ? `
<div class="pagination">
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
`;
const replyForm = this.shadowRoot.getElementById('reply-form');
if (replyForm) {
replyForm.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createPost(thread.uid, formData.get('content'));
e.target.reset();
});
}
}
addNewPost(post) {
const postsList = this.shadowRoot.querySelector('.posts-list');
if (!postsList) return;
const postHtml = `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button">
<span>🤍</span>
<span class="like-count">0</span>
</span>
</div>
</div>
</div>
`;
postsList.insertAdjacentHTML('beforeend', postHtml);
}
updatePost(post) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${post.uid}"]`);
if (postEl) {
const bodyEl = postEl.querySelector('.post-body');
const headerEl = postEl.querySelector('.post-header');
if (bodyEl) bodyEl.textContent = post.content;
if (headerEl && post.edited_at) {
headerEl.innerHTML += ` · Edited ${this.formatDate(post.edited_at)}`;
}
}
}
removePost(postUid) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
postEl.remove();
}
}
formatDate(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
return date.toLocaleDateString();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
customElements.define('snek-forum', SnekForum);
</script>
<snek-forum></snek-forum>
{% endblock %}

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