Author SHA1 Message Date
BordedDev a23c14389b Fix some message rendering 2025-07-24 16:14:51 +02:00
retoor abce2e03d1 Fix xss. 2025-07-24 01:46:34 +02:00
retoor 54d7d5b74e Several updates. 2025-07-24 01:07:33 +02:00
retoor 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: #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
retoor f9f1179db5 Updated channel message. 2025-07-18 23:20:46 +02:00
retoor 04527c286f Updated save. 2025-07-18 17:49:01 +02:00
retoor e23d6571c8 Updated save. 2025-07-18 17:44:45 +02:00
retoor 0c331bbb93 Update. 2025-07-18 00:43:40 +02:00
retoor a2d506cce9 Merge pull request 'maintenance/clean-up-messages' (#65) from BordedDev/snek:maintenance/clean-up-messages into main
Reviewed-on: #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
retoor 9a0ba22fe8 That's how we do in the east. 2025-07-14 19:11:46 +02:00
retoor 8321761358 Update. 2025-07-12 20:58:19 +02:00
retoor bdee3e844f Fixed some routes. 2025-07-12 03:01:39 +02:00
retoor 081d66695c Added forum. 2025-07-12 02:41:57 +02:00
retoor 94c5ce4989 WebDav properies support. 2025-07-10 03:25:40 +02:00
retoor 6a05b56481 Update. 2025-07-08 23:54:28 +02:00
retoor 6f34e96111 Update middleware. 2025-07-08 23:35:35 +02:00
retoor 30e8c17000 Merge pull request 'Add support for embedded players' (#64) from BordedDev/snek:feat/support-player into main
Reviewed-on: #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
retoor 3e006e0bec Merge pull request 'Fix untyped/unknown mime file download' (#63) from BordedDev/snek:bugfix/file-download into main
Reviewed-on: #63
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-04 22:29:58 +02:00
retoor 3a4c46cc0d Update. 2025-07-04 10:38:08 +02:00
retoor 9ba6a28c5c Update. 2025-07-04 09:32:03 +02:00
retoor 5c6d837f10 Update. 2025-07-04 09:29:03 +02:00
retoor 634aa535de Fixed syntax issue. 2025-07-04 06:03:53 +02:00
retoor 8ce607d787 Disable the patch. 2025-07-04 05:50:54 +02:00
retoor 85edc95652 Aded not-show notif when window visible 2025-07-04 05:40:19 +02:00
retoor 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
retoor f77ccd47aa Update. 2025-07-01 10:43:15 +02:00
retoor 006bb4e282 Update. 2025-06-29 21:13:30 +02:00
retoor 2af8ce9bb3 Update. 2025-06-29 21:10:57 +02:00
retoor d67f9f3f3e Update. 2025-06-29 21:09:03 +02:00
retoor fa89cde581 Update. 2025-06-29 21:06:27 +02:00
retoor c05bd57fe1 Update. 2025-06-29 21:03:33 +02:00
retoor 88bb78fb23 Update. 2025-06-29 20:56:44 +02:00
retoor e96cb5bdaa Working perfect. 2025-06-29 20:34:25 +02:00
retoor 86844e330e Update tts/ 2025-06-29 20:16:15 +02:00
retoor cf95fea6c3 Update tts/ 2025-06-29 20:08:13 +02:00
retoor c366f8f0d3 Full STT and TTS. 2025-06-29 19:58:40 +02:00
retoor 1e6f8b9cd8 Update. 2025-06-29 17:39:38 +02:00
retoor 006882cd6f Merge pull request 'Improved site embedding' (#57) from BordedDev/snek:feat/improved-embeding into main
Reviewed-on: #57
2025-06-29 16:24:33 +02:00
retoor ceabf7d73b UPdate. 2025-06-29 16:19:53 +02:00
retoor 874dbe1e43 So closegit diff 2025-06-29 00:12:07 +02:00
retoor 6e0aa3c46c So closegit diff 2025-06-29 00:04:42 +02:00
retoor ea047411ef Whop. 2025-06-27 17:38:35 +02:00
retoor d21201cdce Added image reply. 2025-06-27 17:36:27 +02:00
retoor 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
retoor 27de7a8b4e Update. 2025-06-25 22:46:07 +02:00
retoor 200b0178e7 Update. 2025-06-25 22:41:55 +02:00
retoor 9e237a1d98 Upodate. 2025-06-25 21:38:26 +02:00
retoor db3b5a77bc Upodate. 2025-06-25 21:23:31 +02:00
retoor 8a6aa33974 Added indexes. 2025-06-25 21:17:37 +02:00
retoor 3fc33eaa12 Implemented leetSpeak 2025-06-25 20:59:22 +02:00
retoor f2b35c108c Upodate. 2025-06-25 18:22:16 +02:00
retoor 52ddce5625 Upodate. 2025-06-25 18:11:16 +02:00
retoor 934f334b91 Upodate. 2025-06-25 18:07:11 +02:00
retoor 3130933ea4 Upodate. 2025-06-25 17:54:12 +02:00
retoor 481d7e3ab6 UPdate live type. 2025-06-25 17:31:49 +02:00
retoor e76bf15135 Implemented leetSpeak 2025-06-25 09:10:27 +02:00
retoor 05192a00cd Merge pull request 'Fix file upload' (#59) from BordedDev/snek:bugfix/file-upload into main
Reviewed-on: #59
2025-06-23 00:43:32 +02:00
BordedDev 0de95e3824 Fix file upload 2025-06-23 00:41:09 +02:00
retoor 23055919cd Wieuw 2025-06-21 23:49:04 +02:00
retoor 6350a7851c Merge pull request 'Added youtube music url to options for embedding' (#56) from BordedDev/snek:feat/embed-youtube-music into main
Reviewed-on: #56
2025-06-19 14:37:17 +02:00
retoor 5b3e12ec4b Merge pull request 'Add missing HTML attributes to whitelist' (#58) from BordedDev/snek:bugfix/missing-attributes into main
Reviewed-on: #58
2025-06-19 14:35:40 +02:00
BordedDev 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
BordedDev 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
retoor fd5d1c3401 Update. 2025-06-15 02:30:57 +02:00
retoor 4a6bba7734 Update. 2025-06-15 02:21:49 +02:00
retoor c32d3509b3 Update. 2025-06-15 02:18:45 +02:00
retoor 213136b92e Update. 2025-06-15 02:16:08 +02:00
retoor 0dc75452d6 Removed ddebuglines. 2025-06-14 18:36:40 +02:00
retoor 9d1f6831df Update. 2025-06-14 17:04:14 +02:00
retoor 0341e2f2d8 Update. 2025-06-14 15:42:29 +02:00
retoor c4739ff7d3 Update. 2025-06-14 15:20:04 +02:00
retoor cac691e9ff Update. 2025-06-14 13:35:41 +02:00
retoor 0e12f7a75c Nice. 2025-06-14 13:08:31 +02:00
retoor 3872dafaf1 Nice. 2025-06-14 13:02:53 +02:00
retoor 6a905c1948 Nice. 2025-06-14 12:53:40 +02:00
retoor bf576bc0e3 Update. 2025-06-14 12:42:34 +02:00
retoor 2c182ad48d Update. 2025-06-14 12:34:26 +02:00
retoor a4d29b9d6f Update. 2025-06-14 09:15:08 +02:00
retoor 52538d0181 Update. 2025-06-14 08:31:37 +02:00
retoor 75f12c1971 We did do the cool stuff. 2025-06-14 08:24:48 +02:00
retoor a41da84e3f Progress. 2025-06-12 21:59:37 +02:00
retoor c53b930554 Update. 2025-06-12 01:41:05 +02:00
retoor 1705575985 Update. 2025-06-12 01:39:22 +02:00
retoor 0c0742fce7 Update. 2025-06-12 01:38:45 +02:00
retoor 803ad3dfc6 Update. 2025-06-12 00:09:16 +02:00
retoor f965cc4ecd Update. 2025-06-11 20:20:34 +02:00
retoor ad3f46a9ae Update channel. 2025-06-11 17:27:43 +02:00
retoor 3ea4918ca2 Update. 2025-06-11 17:13:58 +02:00
retoor 26a54848f1 Update. 2025-06-11 01:28:35 +02:00
retoor 015337d75c Update signals. 2025-06-11 01:21:33 +02:00
retoor 5f06d7e04c Update. 2025-06-11 01:21:21 +02:00
retoor 1b2ad3965b Update. 2025-06-10 19:26:52 +02:00
retoor 7adb71efe5 Update. 2025-06-10 19:15:31 +02:00
retoor f35fb12856 Update. 2025-06-10 18:25:21 +02:00
retoor 0295108e6b Updated paths. 2025-06-10 17:04:50 +02:00
retoor a2fa976065 Update url! 2025-06-10 16:51:40 +02:00
retoor b82db127ae Deployment communication. 2025-06-10 16:44:01 +02:00
retoor f5e807cdbf Perfect sizes! 2025-06-10 16:28:30 +02:00
retoor 06f28fd426 Update url! 2025-06-10 15:59:41 +02:00
retoor 60afefac96 Perfect sizes! 2025-06-10 15:44:39 +02:00
retoor 351fef504c Update. 2025-06-10 15:31:31 +02:00
retoor 3a8703d3c6 Update. 2025-06-10 15:31:09 +02:00
retoor 1c58165425 Update contianer. 2025-06-10 13:39:35 +02:00
retoor 38640d8f75 Update contianer. 2025-06-10 13:37:34 +02:00
retoor bb1f7cdb88 Upate. 2025-06-10 13:18:26 +02:00
retoor 4bb1b0997a Update. 2025-06-10 13:04:33 +02:00
retoor f8f1235e60 Toggle container. 2025-06-10 13:00:59 +02:00
retoor fbd72f727a Updated NJET 2025-06-10 11:36:09 +02:00
retoor 62eb1060d9 Update. 2025-06-10 11:28:54 +02:00
retoor 2fc18801a7 Merge pull request 'Fix heif converter being broken' (#55) from BordedDev/snek:bugfix/fix-pictures-again into main
Reviewed-on: #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
retoor 2808952153 Env file. 2025-06-09 15:04:52 +02:00
retoor 149659064d Update. 2025-06-09 06:55:37 +02:00
retoor cb310967cd Update. 2025-06-09 06:47:50 +02:00
retoor 3e7cb9387c Update. 2025-06-09 06:46:43 +02:00
retoor cef451cb17 Fix broken. 2025-06-08 20:39:36 +02:00
retoor da9566c11f Update. 2025-06-08 20:36:45 +02:00
retoor d3c0e138d8 Update. 2025-06-08 18:54:22 +02:00
retoor 7afc24ce51 Update. 2025-06-08 18:54:02 +02:00
retoor 92573ebeb4 Update. 2025-06-08 18:49:47 +02:00
retoor 987bd3a1c7 Removed version. 2025-06-08 12:58:50 +02:00
retoor a95a09a062 Update terminal. 2025-06-08 11:59:16 +02:00
retoor 7b2c93bcef Update. 2025-06-08 11:41:25 +02:00
retoor f02058b0c0 Update. 2025-06-08 03:59:50 +02:00
retoor f35742fec3 Update. 2025-06-08 03:58:38 +02:00
retoor 7b08e6a45e Update Containers. 2025-06-07 20:30:16 +02:00
retoor e99cceaa52 Perfect. 2025-06-07 08:55:09 +02:00
retoor df8c3f1e09 Perfect. 2025-06-07 08:52:08 +02:00
retoor b65ec449a0 Update. 2025-06-07 08:31:23 +02:00
retoor bb9d763416 Update. 2025-06-07 08:27:10 +02:00
retoor bdddbf678c Update. 2025-06-07 07:56:57 +02:00
retoor 389d417c1b Update. 2025-06-07 05:47:54 +02:00
retoor f182c2209e Update. 2025-06-07 05:46:30 +02:00
retoor 8e9ee4bff0 Update. 2025-06-06 16:10:48 +02:00
retoor 7f47a21d40 Update. 2025-06-06 16:03:16 +02:00
retoor 7914511de5 Update. 2025-06-06 15:57:36 +02:00
retoor efbc6a9b4c Update. 2025-06-06 15:08:34 +02:00
retoor 57ac8e772b Update. 2025-06-06 15:02:35 +02:00
retoor 3589f42651 Update manifest. 2025-06-06 14:58:53 +02:00
retoor 1052010dd5 New sneklogo's. 2025-06-06 14:58:53 +02:00
retoor ae26181cf8 New logo's new sizes markdown stripper.py 2025-06-06 14:58:53 +02:00
retoor 66b36509d2 Merge pull request 'Remove redundant check for 'encoding' in push notification body' (#54) from BordedDev/snek:bugfix/removed-encoding into main
Reviewed-on: #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
retoor 9e56ff8494 Made FA locally. 2025-06-06 11:35:56 +02:00
retoor 831b5c17cd Merge pull request 'feat/push-notifications' (#34) from BordedDev/snek:feat/push-notifications into main
Reviewed-on: #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
retoor 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: #52
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-06 11:20:17 +02:00
retoor 10eec5fd6d Update ntsh 2025-06-06 03:46:19 +02:00
retoor d4debeab74 Update. 2025-06-06 03:45:07 +02:00
retoor 5c0ea360cd Update. 2025-06-06 03:41:39 +02:00
retoor 3b38e30df1 Update. 2025-06-06 03:41:06 +02:00
retoor 9a39bedd3a Update. 2025-06-06 03:39:33 +02:00
retoor 9e1eb9f1e5 Update. 2025-06-06 03:36:42 +02:00
retoor 82f8a1ef4a Update. 2025-06-06 03:35:31 +02:00
retoor 7c815898ea Update. 2025-06-06 03:34:47 +02:00
retoor 58a951eec9 Update. 2025-06-06 03:33:45 +02:00
retoor ef75cb3341 MAde elements forbidden. 2025-06-06 03:28:05 +02:00
retoor 1c71c0016b Update. 2025-06-06 03:22:39 +02:00
retoor 1a034041ab Update Security. 2025-06-06 03:04:37 +02:00
retoor c60f9ff4d3 Update. 2025-06-06 02:34:32 +02:00
retoor 3efe388d3f Fixed escape. 2025-06-06 02:22:03 +02:00
retoor 7dc12c9e7f Update flag. 2025-06-06 02:10:28 +02:00
retoor 19c88d786e Merge pull request 'Add styles for spoiler message functionality' (#53) from BordedDev/snek:feat/spoilers into main
Reviewed-on: #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
BordedDev 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
retoor 31d08ec973 Merge pull request 'Refactored message logic to fix issues where they desync' (#51) from BordedDev/snek:bugfix/typing-desync into main
Reviewed-on: #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
retoor 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: #50
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-06-01 12:05:35 +02:00
retoor 557b34b71a Update. 2025-06-01 09:42:06 +02:00
retoor e0255b28ec Update. 2025-06-01 03:38:12 +02:00
retoor 69855fa118 Update. 2025-06-01 03:33:58 +02:00
retoor a07f2680d6 Update. 2025-06-01 03:24:14 +02:00
BordedDev 0738b1ff91 Removed old comment 2025-06-01 00:56:39 +02:00
BordedDev 388f8bc508 Merge branch 'main' into feat/push-notifications 2025-06-01 00:54:17 +02:00
BordedDev 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
retoor d022cff499 Update. 2025-06-01 00:39:53 +02:00
retoor d4a480b5ea Update. 2025-06-01 00:38:22 +02:00
retoor 161ff392d7 Update. 2025-06-01 00:33:47 +02:00
retoor 4e72fbf84b Merge pull request 'Make database asnyc.' (#49) from feat/make-database-async into main
Reviewed-on: #49
2025-06-01 00:28:34 +02:00
retoor bde3819510 Update. 2025-06-01 00:24:45 +02:00
retoor 097889ba3f Make database asnyc. 2025-05-31 23:30:41 +02:00
BordedDev 20dd16734f Cleaned up push register handler 2025-05-31 23:29:45 +02:00
BordedDev b01665f02c Added server (debug/testing) certs 2025-05-31 23:09:56 +02:00
BordedDev aec2da11f2 Implement push notification service and registration 2025-05-31 19:08:55 +02:00
BordedDev 272998f757 Updated conditional check 2025-05-31 15:28:54 +02:00
BordedDev 744d0ace84 Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev 326c549670 Cleaned up code a bit 2025-05-31 15:28:54 +02:00
BordedDev d966c9529b Cleaned up code a bit 2025-05-31 15:28:52 +02:00
BordedDev 4350714534 Added body to push notifications 2025-05-31 15:26:46 +02:00
BordedDev 1a26cacb66 Fix up for push notifications on chrome 2025-05-31 15:26:45 +02:00
BordedDev 0057792802 Initial setup for push notifications (still has issues with fcm aka chrome/opera)
# Conflicts:
#	src/snek/templates/app.html

# Conflicts:
#	src/snek/app.py

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

# Conflicts:
#	src/snek/app.py

# Conflicts:
#	src/snek/app.py
#	src/snek/static/push.js
#	src/snek/static/service-worker.js
#	src/snek/templates/app.html
2025-05-31 15:26:28 +02:00
retoor 4854d40508 Update. 2025-05-31 13:52:04 +02:00
retoor 7dd3133475 Merge pull request 'Add URL embedding functionality with metadata extraction and responsive design' (#48) from BordedDev/snek:feat/url-embedding into main
Reviewed-on: #48
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-30 19:53:22 +02:00
retoor 24dfa39f91 Update chat input. 2025-05-30 19:32:18 +02:00
BordedDev 7ec65f7c12 Cleaned up imports 2025-05-30 02:14:45 +02:00
BordedDev 4f8edef42b Add URL embedding functionality with metadata extraction and responsive design 2025-05-30 02:13:36 +02:00
retoor 7818410d55 Upddated interval. 2025-05-29 02:42:31 +02:00
retoor 1762191b03 Update. 2025-05-28 21:49:30 +02:00
retoor 2df92e809e Added iinput mode. 2025-05-28 13:40:12 +02:00
retoor 59a8d32e40 Added iinput mode. 2025-05-28 13:36:43 +02:00
retoor c3b3963760 Fix cross typing. 2025-05-28 12:31:17 +02:00
retoor a0cd39e3bc Fix cross typing. 2025-05-28 12:29:01 +02:00
retoor e48b2258e0 Made live typing default. 2025-05-28 11:38:16 +02:00
retoor 35aaf8824f Fixed directory does not exist bug. 2025-05-28 10:40:05 +02:00
retoor 76c69ca3ec Is finalized 2025-05-27 23:14:06 +02:00
retoor 9994225911 Update sort. 2025-05-27 22:22:32 +02:00
retoor 03f699e448 Updated online users. 2025-05-27 22:16:41 +02:00
retoor 2fd01a5ab7 Stars update. 2025-05-27 14:28:07 +02:00
retoor 96629113f1 Most recent users. 2025-05-27 14:03:01 +02:00
retoor 973afa0cc2 Scrolled to bottom fix. 2025-05-27 12:33:04 +02:00
retoor 9bc55e771a Scrolled to bottom fix. 2025-05-27 12:30:42 +02:00
retoor 40a292d05e Scrolled to bottom fix. 2025-05-27 12:21:40 +02:00
retoor bf723db2cc Scrolled to bottom fix. 2025-05-27 12:16:47 +02:00
retoor 46052172b2 Scrolled to bottom fix. 2025-05-27 12:14:02 +02:00
retoor 4f777f0003 Scrolled to bottom fix. 2025-05-27 12:12:03 +02:00
retoor b5e1ba72d0 Scrolled to bottom fix. 2025-05-27 12:10:26 +02:00
retoor df120098f9 Scrolled to bottom fix. 2025-05-27 12:08:53 +02:00
retoor 1c1d578db7 Fixed mentions. 2025-05-27 12:06:35 +02:00
retoor 69352fe0b5 Fixed mentions. 2025-05-27 11:45:23 +02:00
retoor fb3980dad0 Removed last-message. 2025-05-27 11:03:25 +02:00
retoor 6c21a1e619 Async load users. 2025-05-27 10:57:18 +02:00
retoor 112c0dc70a Update channel message. 2025-05-27 10:50:09 +02:00
retoor 538a9ce25d Scrolled to bottom fix. 2025-05-27 10:43:44 +02:00
retoor cdc3d10df5 Moved method. 2025-05-27 10:33:00 +02:00
retoor 1b150e3e64 Moved method. 2025-05-27 10:33:00 +02:00
retoor 27dccc324a Moved method. 2025-05-27 10:33:00 +02:00
retoor 1bb68ab33b Moved method. 2025-05-27 10:33:00 +02:00
retoor 3c6ea15d47 Update render to include user info. 2025-05-27 10:33:00 +02:00
retoor 36e663e1ed added animal view. 2025-05-27 10:33:00 +02:00
retoor 8d2e0381a7 Merge pull request 'Enhance mobile responsiveness by updating viewport settings and restructuring layout' (#47) from BordedDev/snek:bugfix/page-resize-on-mobile into main
Reviewed-on: #47
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:27:33 +02:00
retoor f67d7b35f1 Merge pull request 'Potential missing letter fix' (#46) from BordedDev/snek:bugfix/potential-lag-fix into main
Reviewed-on: #46
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:25:56 +02:00
BordedDev 9ec62f7471 Enhance mobile responsiveness by updating viewport settings and restructuring layout 2025-05-27 00:23:41 +02:00
retoor 6bbbc41360 Update. 2025-05-25 20:50:32 +02:00
retoor d3844ac7a7 Resoted avatars. 2025-05-25 19:01:28 +02:00
retoor 9378e95a5b Resoted avatars. 2025-05-25 18:55:39 +02:00
retoor 5e4c4ce228 Resoted avatars. 2025-05-25 18:51:55 +02:00
retoor ffc373db62 Channel support. 2025-05-25 12:30:43 +02:00
retoor 60266bf0dc Channel support. 2025-05-25 12:30:26 +02:00
BordedDev 8393a80022 Potential missing letter fix 2025-05-25 02:46:49 +02:00
retoor 234edf4756 Merge pull request 'Fixed double messaging when live typing' (#45) from BordedDev/snek:bugfix/live-typing into main
Reviewed-on: #45
2025-05-25 02:29:04 +02:00
BordedDev 5fd401bfb6 Fixed double messaging when live typing 2025-05-25 02:23:52 +02:00
retoor 5663a5f376 Update. 2025-05-25 01:28:42 +02:00
retoor 81327a9e20 Update. 2025-05-25 01:25:48 +02:00
retoor 2a5b9ad276 Merge pull request 'Fix CSS selector for the last message display' (#44) from BordedDev/snek:bugfix/fix-last-message-time-reply into main
Reviewed-on: #44
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 18:46:43 +02:00
BordedDev 662e71c621 Fix CSS selector for the last message display 2025-05-24 18:41:12 +02:00
retoor 636adfd997 Update performance. 2025-05-24 15:00:26 +02:00
retoor b94f7a9532 Update. 2025-05-24 14:45:53 +02:00
retoor f954a34384 Update performance. 2025-05-24 14:22:12 +02:00
retoor 6a74263606 Update performance. 2025-05-24 14:18:38 +02:00
retoor 0bf714061c Update. 2025-05-24 12:58:49 +02:00
retoor e4e2e919c2 Update. 2025-05-24 12:58:49 +02:00
retoor 7fe4289f42 Merge pull request 'Made datetime/reply visible when date range is long, also fixes opacity to 1 for the last message's time display' (#39) from BordedDev/snek:bugfix/opacity-for-last-message into main
Reviewed-on: #39
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 01:48:07 +02:00
BordedDev 3ce866b7da Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 18:14:32 +02:00
retoor 43982c16fa Merge pull request 'Fix timestamp parsing by correcting string slicing for start time' (#40) from BordedDev/snek:bugfix/youtube-timestamp into main
Reviewed-on: #40
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-23 18:13:50 +02:00
BordedDev e33e4196ab Merge branch 'main' into bugfix/youtube-timestamp 2025-05-23 15:45:56 +02:00
BordedDev 2c506db4e4 Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 15:34:30 +02:00
retoor 9b9d356849 update. 2025-05-23 15:26:47 +02:00
retoor 30b7871583 Update. 2025-05-23 07:07:19 +02:00
retoor 1c873b7d02 Update. 2025-05-23 07:02:45 +02:00
retoor 539fb262b2 Update. 2025-05-23 07:00:28 +02:00
retoor 3bf09f9083 Update. 2025-05-23 06:48:18 +02:00
retoor a55d15b635 The force. 2025-05-23 03:01:20 +02:00
retoor 431748c489 Updated dem glow. 2025-05-23 02:39:48 +02:00
retoor a0fb214332 Updated dem glow. 2025-05-23 01:17:41 +02:00
retoor f0545cbf02 Format. 2025-05-23 01:17:41 +02:00
BordedDev a11c336cf5 Fix timestamp parsing by correcting string slicing for start time 2025-05-22 08:10:39 +02:00
BordedDev 6b083f8b1b Added feature to show time and reply when messages are from a long time before the next message 2025-05-22 00:19:39 +02:00
BordedDev 89afbba165 Set opacity to 1 for the last message's time display 2025-05-22 00:00:51 +02:00
retoor b2a4887e23 Merge pull request 'Enhance hover effect for time display and avatar visibility' (#38) from BordedDev/snek:feat/show-time-reply-hover into main
Reviewed-on: #38
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-21 23:36:25 +02:00
BordedDev 9f577875f2 Enhance hover effect for time display and avatar visibility 2025-05-21 22:30:15 +02:00
retoor c322d6147a Merge pull request 'bugfix/youtube-embed' (#37) from BordedDev/snek:bugfix/youtube-embed into main
Reviewed-on: #37
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-20 22:02:16 +02:00
retoor 87b6b3362d New avatar. 2025-05-20 04:20:10 +02:00
BordedDev d261f54327 Merge branch 'main' into bugfix/youtube-embed 2025-05-20 03:34:05 +02:00
retoor 59a815f85a Update. 2025-05-19 01:35:34 +02:00
retoor 2e837f96c5 Update. 2025-05-19 01:28:22 +02:00
retoor 00fce6bd68 Update. 2025-05-19 01:23:12 +02:00
retoor 8a85cd7990 Update. 2025-05-19 01:13:18 +02:00
retoor db5431d77d Update. 2025-05-19 01:07:17 +02:00
retoor 527b010b24 Added containers. 2025-05-19 01:07:17 +02:00
retoor e1727caa5f Update. 2025-05-18 17:48:22 +02:00
retoor c45b61681d Update. 2025-05-18 16:57:14 +02:00
retoor e09652413f Added nice repo system. 2025-05-18 16:55:02 +02:00
BordedDev 0f337e569f Fixed gif resizing 2025-05-18 14:51:38 +02:00
BordedDev 59a2668c8c Merge branch 'main' into bugfix/youtube-embed 2025-05-18 03:26:53 +02:00
retoor e79abf4a26 Update stars. 2025-05-17 17:46:59 +02:00
BordedDev 53811ca9b2 Re-added webp fallback 2025-05-17 13:55:46 +02:00
BordedDev 1bed47fbf5 Re-added webp fallback 2025-05-17 13:29:25 +02:00
BordedDev ffb22165da Fix YouTube embed parsing and add support for start time; handle missing channel attachments 2025-05-17 13:23:32 +02:00
retoor 48c3daf398 Update. 2025-05-17 00:54:15 +02:00
retoor c0b4ba715c t: 2025-05-17 00:53:27 +02:00
retoor 00557ec9ea Update. 2025-05-16 01:38:42 +02:00
retoor c387225a6e Update. 2025-05-16 00:41:40 +02:00
retoor 93462d4c4b Update. 2025-05-16 00:32:54 +02:00
retoor c5b55399a1 UPdate. 2025-05-16 00:04:19 +02:00
retoor 79c39828f0 update. 2025-05-15 23:30:23 +02:00
retoor dd80f3732b Update. 2025-05-15 23:16:28 +02:00
retoor 25d109beed Update. 2025-05-15 19:32:40 +02:00
retoor db6d6c0106 Update live type. 2025-05-15 13:18:53 +02:00
retoor af1cf4f5ae Push 2025-05-13 23:33:24 +02:00
retoor 0ea0cd96db Update. 2025-05-13 22:54:21 +02:00
retoor 3858dcbd62 Update. 2025-05-13 21:25:49 +02:00
retoor b55d74fb12 Update. 2025-05-13 20:35:42 +02:00
retoor a21e3590ef UPdate. 2025-05-13 20:30:48 +02:00
retoor 319c1b1b52 UPdate. 2025-05-13 20:28:31 +02:00
retoor 964a747f42 UPdate. 2025-05-13 20:27:26 +02:00
retoor 12d2870424 UPdate. 2025-05-13 20:25:00 +02:00
retoor 015b188d5e UPdate. 2025-05-13 20:24:29 +02:00
retoor 8cd2f16c5c UPdate. 2025-05-13 20:20:43 +02:00
retoor d09055986e UPdate. 2025-05-13 20:18:47 +02:00
retoor 2e324ff118 Update. 2025-05-13 19:13:50 +02:00
retoor adad5ed4fe Update. 2025-05-13 19:08:18 +02:00
retoor ba3152f553 Update. 2025-05-13 18:33:05 +02:00
retoor a4bea94495 Windows friendly solution. 2025-05-13 18:32:59 +02:00
retoor ac2f68f93f Merge pull request 'Add image conversion and resizing support in channel attachments' (#36) from BordedDev/snek:feat/image-conversion-resizing into main
Reviewed-on: #36
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-13 18:19:57 +02:00
BordedDev f156a153de Add image conversion and resizing support in channel attachments 2025-05-12 01:47:54 +02:00
retoor c48b84bf3a Update. 2025-05-11 07:52:58 +02:00
retoor 01846bf23f Update. 2025-05-11 07:52:22 +02:00
retoor 2c90044185 xxx 2025-05-10 21:44:58 +02:00
retoor 4d7566de9b Update. 2025-05-10 20:40:56 +02:00
retoor 9133b7c3ce Update. 2025-05-10 20:38:32 +02:00
retoor 3412aa0bf0 Update. 2025-05-10 15:08:28 +02:00
retoor f0591d4939 UPdate. 2025-05-10 15:03:50 +02:00
retoor dd108c2004 Update. 2025-05-09 17:37:53 +02:00
retoor 44ac1d2bfa Update. 2025-05-09 15:55:51 +02:00
retoor 1616e4edb9 revert 17c6124a57
revert Minify.
2025-05-09 14:57:22 +02:00
retoor 4c34d7eda5 New stuff. 2025-05-09 14:30:53 +02:00
retoor 17c6124a57 Minify. 2025-05-09 14:19:29 +02:00
retoor 95ad49df43 progress. 2025-05-09 14:08:46 +02:00
retoor 7e8ae1632d Update. 2025-05-09 09:56:23 +02:00
retoor e5d155e124 Update. 2025-05-09 09:18:55 +02:00
retoor 3ae30f1f76 Do it. 2025-05-09 09:09:33 +02:00
retoor adb59eff68 Do it. 2025-05-09 08:54:33 +02:00
retoor e06776d81d Performance. 2025-05-09 08:24:43 +02:00
retoor a5aac9a337 Patch 2025-05-09 07:55:08 +02:00
retoor ee40c905d4 Update. 2025-05-09 05:38:29 +02:00
retoor c56bf4fb49 Update. 2025-05-09 02:30:43 +02:00
retoor 5b28044d9e Update. 2025-05-09 02:17:12 +02:00
retoor e359a8ebe2 Update. 2025-05-09 02:16:40 +02:00
retoor b867b6ba78 Update. 2025-05-09 02:15:57 +02:00
retoor ac570d036c Update. 2025-05-09 02:08:43 +02:00
retoor 165dda3210 Update. 2025-05-09 01:36:48 +02:00
retoor 02a0253c1d YEah.. 2025-05-09 01:33:59 +02:00
retoor 3c0fea6812 ADded tmux. 2025-05-08 02:05:15 +00:00
retoor 31062fddbf Update. 2025-05-08 03:32:43 +02:00
retoor d0dd342e27 Update Makefile. 2025-05-08 03:22:32 +02:00
retoor 3c1d5d601f Update. 2025-05-08 03:20:06 +02:00
retoor 49ec99ef01 Focus while upload. 2025-05-08 01:10:56 +02:00
retoor 8799662159 Focus while upload. 2025-05-08 01:09:59 +02:00
retoor f6706c165e Focus while upload. 2025-05-08 01:07:42 +02:00
retoor 0a3e151377 Focus while upload. 2025-05-08 01:04:29 +02:00
retoor e153811ff3 Focus while upload. 2025-05-08 01:03:11 +02:00
retoor fa59dbc095 Focus while upload. 2025-05-08 00:59:18 +02:00
retoor d6d2f2892b Focus while upload. 2025-05-08 00:56:55 +02:00
retoor 707788583a Focus while upload. 2025-05-08 00:31:21 +02:00
retoor b0666a0090 Merge pull request 'Added file paste/drop support' (#35) from BordedDev/snek:feat/copy-paste-drag-drop into main
Reviewed-on: #35
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-06 23:27:25 +02:00
BordedDev 0f6eb5c043 Merge branch 'main' into feat/copy-paste-drag-drop 2025-05-06 23:17:08 +02:00
retoor 529ebd23fc Fixed upload. 2025-05-06 23:16:03 +02:00
BordedDev f7fda2d2c9 Added paste support
Added file drop support
2025-05-06 23:14:52 +02:00
retoor c709ee11c9 Updated security. 2025-05-06 23:02:09 +02:00
retoor 6312dfae47 Added windows exception. 2025-05-06 22:17:04 +02:00
retoor 061da150f9 Update 2025-05-06 22:00:07 +02:00
retoor 46a8b612b4 Added parameters and executable. 2025-05-06 21:44:45 +02:00
retoor 1cd0b54656 Update. 2025-04-17 00:05:25 +02:00
retoor 4cc70640e4 Upadte. 2025-04-14 23:20:05 +02:00
retoor c36ce17da5 Upadte. 2025-04-14 23:16:52 +02:00
retoor 3cfb79c8f5 Upadte. 2025-04-14 23:09:23 +02:00
retoor d4f5a46409 Upadte. 2025-04-14 23:00:05 +02:00
retoor 0fa0488385 Upadte. 2025-04-14 22:54:12 +02:00
retoor 9fb6e64655 Update. 2025-04-14 22:41:14 +02:00
retoor a3abd854bb Updates. 2025-04-14 22:31:46 +02:00
retoor 3b05acffd2 Updates. 2025-04-14 22:31:26 +02:00
retoor bee7d828cd Update. 2025-04-13 23:31:52 +02:00
retoor 8ae9aac045 Fixed auth. 2025-04-13 20:28:15 +02:00
retoor e4b0625799 Fixed auth. 2025-04-13 20:26:02 +02:00
retoor 4a770848a6 Fixed search space bug. 2025-04-13 19:10:10 +02:00
retoor 823892a302 PRoces handler. 2025-04-13 14:47:10 +02:00
retoor 9b49e659e5 Update .rcontext.txt 2025-04-13 11:51:32 +02:00
retoor ec9af49f29 Update .rcontext.txt 2025-04-13 11:46:40 +02:00
retoor 22668f8a72 Update vibe coding. 2025-04-13 11:39:12 +02:00
retoor a1840cd034 Sats. 2025-04-13 05:08:20 +02:00
retoor bc65752ea2 Cache stats. 2025-04-13 05:06:53 +02:00
retoor 3594ac1f59 Performance upgrade. 2025-04-10 13:34:32 +02:00
retoor 0e6fbd523c update. 2025-04-10 08:37:05 +02:00
retoor 743593affe Formatting. 2025-04-09 15:21:23 +02:00
retoor 44dd77cec5 Shed. 2025-04-09 15:12:34 +02:00
retoor 8fa216c06c New video embedding 2025-04-09 11:09:00 +02:00
retoor c529fc87fd New video embedding 2025-04-09 11:07:09 +02:00
retoor 656ea5f90e New video embedding 2025-04-09 11:03:39 +02:00
retoor 2582df360a New video embedding 2025-04-09 11:02:45 +02:00
retoor 6673f7b615 New video embedding 2025-04-09 10:59:09 +02:00
retoor 94e94cf7ca New video embedding 2025-04-09 10:56:52 +02:00
retoor e6bd7aa152 New video embedding 2025-04-09 10:55:30 +02:00
retoor 087f9c10b4 New video embedding 2025-04-09 10:52:39 +02:00
retoor 6138cad782 New video embedding 2025-04-09 10:46:53 +02:00
retoor c6575d8e52 New video embedding 2025-04-09 10:43:46 +02:00
retoor b0a97ad267 New video embedding 2025-04-09 10:35:15 +02:00
retoor b31c286a8b New video embedding 2025-04-09 10:34:57 +02:00
retoor 13f1d2f390 Performance upgrade, lock fix. 2025-04-08 21:32:18 +02:00
retoor d23ed3711a Update. 2025-04-08 20:31:15 +02:00
retoor d2e2bb8117 Update. 2025-04-08 05:01:27 +02:00
retoor d71d5da6bc Updates. 2025-04-08 04:20:35 +02:00
retoor 75593fd6bb Merge pull request 'Potential fix for manifest, the icons were being marked as instability since they were the wrong size which might fix firefox android' (#32) from BordedDev/snek:bugfix/webmanifest-instability into main
Reviewed-on: #32
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-04-07 11:24:13 +00:00
BordedDev c2b8061ac2 Potential fix for manifest, the icons were being marked as instability since they were the wrong size which might fix firefox android 2025-04-06 23:47:18 +02:00
retoor 6948220746 Progress 2025-04-03 08:35:25 +02:00
retoor d10768403d Progress. 2025-04-03 08:34:25 +02:00
retoor 81479e7058 Updates. 2025-04-02 15:02:55 +02:00
retoor 99b2beeab0 Updates. 2025-04-02 14:55:18 +02:00
retoor b365afc910 Update. 2025-04-02 10:52:02 +02:00
retoor 27c0abea31 Upated for phone. 2025-04-01 21:23:10 +02:00
retoor e3c997302b Upated for phone. 2025-04-01 21:09:58 +02:00
retoor d24627b35f Upated for phone. 2025-04-01 21:06:07 +02:00
retoor fbd4fa4e66 Update css. 2025-04-01 20:28:56 +02:00
retoor 7c52c2d9d5 Update css. 2025-04-01 20:23:35 +02:00
retoor 18b1ec20b6 Update css. 2025-04-01 20:20:18 +02:00
retoor 87b48af551 Update css. 2025-04-01 20:15:04 +02:00
retoor 32e0c959e8 Progress. 2025-04-01 20:15:04 +02:00
retoor 2a47c0ba5e Fixed bug. 2025-03-30 09:34:13 +02:00
retoor 8058e4a4b0 New version. 2025-03-30 07:02:35 +02:00
retoor d5917b9454 New version. 2025-03-30 06:54:25 +02:00
retoor 3926b2d837 Update storage. 2025-03-29 10:50:40 +01:00
retoor 6dca3a96e1 Added webdav. 2025-03-29 07:27:56 +01:00
retoor 886d21999c Added webdav. 2025-03-29 07:19:08 +01:00
retoor 9a923f6bdd WEebdav. 2025-03-29 07:15:53 +01:00
retoor 29139d5d0c Added webdav. 2025-03-29 07:13:23 +01:00
retoor fe1b3d6d19 Fixed avatar issue 2025-03-29 01:21:33 +01:00
retoor 5fbcadad8b Terminal Update. 2025-03-28 02:41:57 +01:00
retoor 9e50b2a6d3 Transactions. 2025-03-27 21:14:01 +01:00
retoor 6f043d2139 Transactions. 2025-03-27 21:12:19 +01:00
retoor 73e8779bdc Transactions. 2025-03-27 21:11:02 +01:00
retoor 6ad3844f03 Transactions. 2025-03-27 21:07:04 +01:00
retoor 8810679fd8 Transactions. 2025-03-27 21:01:17 +01:00
retoor 9d5815ed10 Transactions. 2025-03-27 21:00:34 +01:00
retoor e6f702a6b4 Transactions. 2025-03-27 20:56:35 +01:00
retoor 145373399d Transactions. 2025-03-27 20:41:04 +01:00
retoor 13ce09a5c5 Transactions. 2025-03-27 20:20:48 +01:00
retoor bd5bb5ae65 Transaction. 2025-03-27 20:10:05 +01:00
retoor 71a206a19f Removed useless prints. 2025-03-27 17:30:06 +01:00
retoor 87c189b3fe Removed useless prints. 2025-03-27 17:28:54 +01:00
retoor 877ef7970d Bugfix. 2025-03-27 17:15:39 +01:00
retoor 5390b8bdc3 Update. 2025-03-27 17:10:02 +01:00
retoor af4a70e894 Repaired websockets. 2025-03-23 18:52:12 +01:00
retoor 529606955a Repaired websockets. 2025-03-23 18:46:10 +01:00
retoor 1de2c55966 Update drive. 2025-03-23 14:38:53 +01:00
retoor dec2281ac8 Update drive. 2025-03-23 14:08:10 +01:00
retoor 7b32a7eba4 Update drive. 2025-03-23 13:57:29 +01:00
retoor c5c160baae Updated bsahrc. 2025-03-23 03:22:51 +01:00
retoor c2d9af807a Terminal changes. 2025-03-23 02:53:36 +01:00
retoor 0bc24e8d2e New executor. 2025-03-23 02:16:37 +01:00
retoor c8461342fd Update. 2025-03-22 22:46:19 +01:00
retoor 78c631e6c7 Updated .bashrc. 2025-03-22 21:01:15 +01:00
retoor c72b015073 Update .bashrc 2025-03-22 20:55:57 +01:00
retoor 604e27ce10 Changes. 2025-03-22 20:42:38 +01:00
retoor 6e68408ddf Update. 2025-03-22 20:04:20 +01:00
retoor 013d4adce5 Drive access. 2025-03-22 19:57:39 +01:00
retoor 7dcabde2ed Update. 2025-03-22 18:16:36 +01:00
retoor 5ba239caa8 Changes by r. 2025-03-20 02:12:05 +01:00
retoor 3960390ec4 Updated dump script. 2025-03-18 21:06:28 +00:00
retoor 70db15bf27 Update. 2025-03-18 00:13:25 +00:00
retoor 3c6a0944d6 Added dump script. 2025-03-18 00:47:18 +01:00
retoor 825ece4e78 Merge pull request 'Pollyfill for Promise.withResolvers' (#31) from BordedDev/snek:feat/pollyfill-promise.withResolvers into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/31
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-17 21:09:55 +00:00
BordedDev 965dc930a9 Undid formatting 2025-03-17 22:06:52 +01:00
BordedDev 7fa2817f77 Added Promise.withResolvers pollyfill 2025-03-17 22:05:46 +01:00
retoor 39fa8fa0cd Layout update. 2025-03-17 17:01:01 +01:00
retoor 54416ee84f Added block end. 2025-03-17 09:15:49 +01:00
retoor 287e10d8aa Added tracker. 2025-03-16 19:46:15 +01:00
retoor 2ba28c193a Change color. 2025-03-16 06:45:31 +01:00
retoor 819cf8381c Change color. 2025-03-16 06:42:05 +01:00
retoor e62a855409 Merge pull request 'bugfix/socket-reconnetion' (#29) from BordedDev/snek:bugfix/socket-reconnetion into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/29
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-16 05:02:03 +00:00
BordedDev c9c070c497 Reformated file 2025-03-16 04:47:03 +00:00
BordedDev 4a8a614adb Replaced socket code 2025-03-16 04:47:03 +00:00
retoor a9663c8170 Merge pull request 'Updated files to support modules' (#28) from BordedDev/snek:feat/convert-to-modules into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/28
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-16 01:40:06 +00:00
BordedDev a4d79b06c4 Updated files to support modules 2025-03-15 19:10:52 +01:00
retoor 752f3df13a Merge pull request 'Added updated input styling to other pages' (#27) from BordedDev/snek:feat/input-styles into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/27
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-15 15:27:38 +00:00
BordedDev 5b70bb9ea5 Added updated input styling to other pages 2025-03-15 15:53:24 +01:00
retoor 17c9731b9f Done stuff. 2025-03-14 23:02:58 +01:00
retoor d8b43dbd08 Added reply. 2025-03-13 20:36:14 +01:00
retoor 0f950218d6 Added reply. 2025-03-13 19:52:03 +01:00
retoor 0fad298fc0 Blacked. 2025-03-13 19:51:01 +01:00
retoor c55927aa9c Added reply. 2025-03-13 19:21:38 +01:00
retoor 5cfcafe082 Added reply. 2025-03-13 19:15:10 +01:00
retoor c6c2766381 Added better file handling. 2025-03-11 10:10:50 +01:00
retoor c4e3f1fc1f List fix. 2025-03-10 11:53:54 +01:00
retoor 91d8f3efd1 Added alter. 2025-03-09 20:38:14 +01:00
retoor d9ac1813ba Merge pull request 'Added form preloading, and autofocus on the first input element' (#26) from BordedDev/snek:main into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/26
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-09 18:37:34 +00:00
BordedDev fd07001983 Returned a semi 2025-03-08 21:18:20 +01:00
BordedDev 0266b2a559 Added form preloading, and autofocus on the first input element
Also adds the preloading functionality to login & register pages
2025-03-08 20:06:11 +00:00
retoor 11b8f0e744 Changes. 2025-03-08 20:29:48 +01:00
retoor 24a504e3a7 Changes. 2025-03-08 20:29:33 +01:00
retoor d6061cb45b Changes. 2025-03-08 20:27:05 +01:00
retoor a219ce4d79 Update sorting. 2025-03-08 20:21:24 +01:00
retoor aedfe9aa94 Merge pull request 'Tweaks for login/registration and base + image roundness' (#25) from BordedDev/snek:main into main
Reviewed-on: https://molodetz.nl/retoor/snek/pulls/25
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-03-08 18:53:02 +00:00
BordedDev dd5a9a23e8 Merge branch 'main' into main 2025-03-08 18:40:43 +00:00
BordedDev e91ae4584a Undo auto formatting 2025-03-08 19:35:22 +01:00
BordedDev ad7eab9717 Tweaked the back button position 2025-03-08 19:07:09 +01:00
BordedDev 62aa15a4b4 Added head block 2025-03-08 18:59:00 +01:00
BordedDev c9113ca095 Tweaked form input styling 2025-03-08 18:56:55 +01:00
retoor 8e195a49e3 Update. 2025-03-08 18:41:37 +01:00
BordedDev 7c22a70722 Added additional meta info to base.html 2025-03-08 18:25:15 +01:00
BordedDev 804556b74d Fixed issues with auto complete not working correctly with form sync 2025-03-08 18:09:14 +01:00
BordedDev 6ecd356cc0 Made improvement to page titles (mainly setting a default, login and regsiter) 2025-03-08 18:04:53 +01:00
retoor 0a9b66d2f7 Updated search. 2025-03-08 17:33:17 +01:00
BordedDev 98c2213a86 Fix rounded corners on follow-up messages 2025-03-08 17:25:56 +01:00
retoor 5a72c8cd83 Update. 2025-03-08 08:25:49 +01:00
retoor 1b72063a5b Channel list. 2025-03-08 07:22:14 +01:00
retoor 24260f9c37 Update. 2025-03-08 04:24:39 +01:00
retoor 9292e3b8f3 Update. 2025-03-08 04:11:34 +01:00
retoor 095be5892d Update. 2025-03-08 04:10:05 +01:00
retoor 37f6725f2f Update. 2025-03-08 04:07:21 +01:00
retoor e3afc1ba6e Fix socket issue. 2025-03-08 03:51:54 +01:00
retoor a1afaacb2e New stuff. 2025-03-07 20:59:11 +01:00
retoor 5b78165fb7 New stuff. 2025-03-07 20:58:53 +01:00
retoor 1807cff67d Notifications accept. 2025-03-05 18:51:50 +01:00
retoor 0613f6f54d Notifications accept. 2025-03-05 18:48:27 +01:00
retoor edb35b5707 Notifications accept. 2025-03-05 18:40:53 +01:00
retoor 30fe0bfae7 Notifications accept. 2025-03-05 18:37:44 +01:00
retoor 8f137cf8e6 Notifications accept. 2025-03-05 18:24:32 +01:00
retoor dd11c8da5a Notifications accept. 2025-03-05 18:18:48 +01:00
retoor 4df6055566 Notifications accept. 2025-03-05 18:18:06 +01:00
retoor 8d3d7327d7 Notifications accept. 2025-03-05 18:17:43 +01:00
retoor afbf53938b Notifications accept. 2025-03-05 18:09:54 +01:00
retoor e1324e99bf Notifications accept. 2025-03-05 18:05:34 +01:00
retoor d7851e6457 Notifications accept. 2025-03-05 18:02:02 +01:00
retoor 84d7b11f24 Notifications accept. 2025-03-05 17:59:59 +01:00
retoor 580ec5ab0d Notifications accept. 2025-03-05 17:52:18 +01:00
retoor 578c182f27 Notifications accept. 2025-03-05 17:51:25 +01:00
retoor c3c94461c2 Update service worker. 2025-03-05 17:19:24 +01:00
retoor 45e3239cc0 Upload changes. 2025-03-03 00:35:08 +01:00
retoor e469e27abf Optinal indexes. 2025-03-02 14:59:25 +01:00
retoor 4620ebb800 Channel check. 2025-03-02 14:49:38 +01:00
retoor 66b85d146a Non required avatar stuff. 2025-02-28 15:42:52 +01:00
retoor da1be6301c Text 2025-02-26 06:21:48 +01:00
retoor 162cfe3945 Update avatar. 2025-02-26 00:46:48 +01:00
retoor e280e87764 Update avatar. 2025-02-26 00:40:18 +01:00
retoor 5af4e5754b Update avatar. 2025-02-26 00:22:36 +01:00
retoor 076fbb30fb Added sidebar stuff. 2025-02-22 01:21:57 +01:00
retoor fbe95d6631 Side bar fix. 2025-02-22 01:21:44 +01:00
retoor 8ea41bb592 Addded notifs. 2025-02-21 00:41:22 +01:00
retoor 54920e1545 Added sound. 2025-02-21 00:24:56 +01:00
retoor a7e0e5a3f8 Chaned docker compose. 2025-02-20 23:24:44 +00:00
retoor 3623286a9d Changed server. 2025-02-20 05:56:31 +01:00
retoor 821db3cb1a Changed server. 2025-02-19 23:23:24 +01:00
retoor e06824f4ec Changed server. 2025-02-19 23:21:08 +01:00
retoor 2ad5a7b1f4 Changed server. 2025-02-19 23:20:10 +01:00
retoor 736123c4aa Aadded a1 2025-02-19 22:32:15 +01:00
retoor 60404c6fd3 Update. 2025-02-18 13:48:48 +01:00
retoor 91a21db89b Update. 2025-02-18 13:47:34 +01:00
retoor c6620ad70a Added profiler. 2025-02-18 13:29:33 +01:00
retoor ebb520dd4a Updated asyncio debugging. 2025-02-18 12:38:44 +01:00
retoor 3ccbe8be5c Updated asyncio debugging. 2025-02-18 12:28:49 +01:00
retoor c745f60997 Update manifest. 2025-02-17 22:53:20 +01:00
retoor 6c7266f204 Fixed. 2025-02-17 22:37:15 +01:00
retoor e21880b4f5 Update. 2025-02-17 22:31:17 +01:00
retoor 2ab4341d00 Refactor. 2025-02-17 20:34:24 +01:00
retoor 6555e4f826 Refactor. 2025-02-17 20:31:18 +01:00
retoor 2595594c3a Scroll infinite. 2025-02-17 20:20:32 +01:00
retoor bb2b4b61b4 Scroll infinite. 2025-02-17 20:17:17 +01:00
retoor c042af8b80 Scroll infinite. 2025-02-17 20:00:35 +01:00
retoor 6bdc6a7347 Scroll infinite. 2025-02-17 19:57:48 +01:00
retoor 1b6ebf5008 Scroll infinite. 2025-02-17 19:49:29 +01:00
retoor f0d76bd46a Scroll infinite. 2025-02-17 19:48:03 +01:00
retoor 1a608d8cfb Scroll infinite. 2025-02-17 19:45:46 +01:00
retoor 2e86ca2a3f Scroll infinite. 2025-02-17 19:44:31 +01:00
retoor c77d2fb782 Scroll infinite. 2025-02-17 19:42:45 +01:00
retoor bc8a296223 Scroll infinite. 2025-02-17 19:36:57 +01:00
retoor 6c58f4b26c Scroll infinite. 2025-02-17 19:36:45 +01:00
retoor 3230c9f93b Scroll infinite. 2025-02-17 19:32:28 +01:00
retoor 5b03ecda3f Scroll infinite. 2025-02-17 19:30:50 +01:00
retoor 8b98935d11 Scroll infinite. 2025-02-17 19:26:16 +01:00
retoor 24cd378c9d Scroll infinite. 2025-02-17 19:21:03 +01:00
retoor 2fb6be753e Scroll infinite. 2025-02-17 19:11:14 +01:00
retoor 60efe6ee8a Scroll infinite. 2025-02-17 19:09:42 +01:00
retoor 104ee27766 Scroll infinite. 2025-02-17 19:06:10 +01:00
retoor 162f89f9d0 Scroll infinite. 2025-02-17 19:04:59 +01:00
retoor 95a8a45842 Scroll infinite. 2025-02-17 19:03:40 +01:00
retoor 3ee7c6d802 Scroll infinite. 2025-02-17 18:56:35 +01:00
retoor 1792686531 Scroll infinite. 2025-02-17 18:54:25 +01:00
retoor aa5703e62f Scroll infinite. 2025-02-17 18:52:30 +01:00
retoor 33bc695cda Scroll infinite. 2025-02-17 18:52:19 +01:00
retoor 9e3b9ae326 Scroll infinite. 2025-02-17 18:50:20 +01:00
retoor 477ca5917a Scroll infinite. 2025-02-17 18:42:59 +01:00
retoor 8c33bc63d6 Focus textbox. Updated pagination. 2025-02-17 13:37:05 +01:00
retoor 2e69ac5921 Added scroll only when has reached bottom. 2025-02-17 10:51:26 +01:00
retoor f28be3ba55 Fix. 2025-02-17 04:51:21 +01:00
retoor ea4196af8f Fix. 2025-02-17 04:49:09 +01:00
retoor be956a13db Changes 2025-02-16 23:05:56 +01:00
retoor 7bcc67c6d3 Progress. 2025-02-16 22:53:28 +01:00
retoor 263595fc7e Progress. 2025-02-16 22:49:37 +01:00
retoor c463dc6dca Updated video player. 2025-02-16 22:41:33 +01:00
retoor 7c4334fe7b Updated video player. 2025-02-16 22:23:11 +01:00
retoor d1396801c0 Spread to new users. 2025-02-15 23:03:45 +01:00
retoor 9c3abdec26 Spread to new users. 2025-02-15 23:01:43 +01:00
retoor 53be4b060a Spread to new users. 2025-02-15 21:56:26 +01:00
retoor 1f8ebf71d0 Update socket communicaton and removed prints. 2025-02-15 21:07:02 +01:00
retoor 37da903936 Added channel list. 2025-02-13 20:21:34 +01:00
retoor 3baa6e53df Added channel list. 2025-02-13 19:47:05 +01:00
retoor b6eba60843 Echo service. 2025-02-11 21:11:21 +01:00
retoor 2541fc536a Iframe funcs. 2025-02-11 19:34:47 +01:00
retoor 2cfb8fe308 Iframe funcs. 2025-02-11 19:30:55 +01:00
retoor be35a6caf0 Added private chat. 2025-02-11 15:42:54 +01:00
retoor bfe4b351c1 Added private chat. 2025-02-11 15:42:03 +01:00
retoor 8fe24f711c Added private chat. 2025-02-11 15:40:04 +01:00
retoor ca463b79a8 Added private chat. 2025-02-11 15:38:05 +01:00
retoor 8a59ddd210 Added private chat. 2025-02-11 15:34:31 +01:00
retoor 3f75c8d5f9 Added online status 2025-02-10 16:34:26 +01:00
retoor 087ab1a8a5 Added online status 2025-02-10 16:32:30 +01:00
retoor 48891c4386 Added online status 2025-02-10 16:24:43 +01:00
retoor 688e7fbf0e Added online status 2025-02-10 16:22:34 +01:00
retoor 54c40c6b85 Added online status 2025-02-10 16:18:08 +01:00
retoor 49c0f932ab Updated cors. 2025-02-09 23:49:54 +01:00
retoor 0f400a0b6a Upgrade css. 2025-02-09 16:14:00 +01:00
retoor e75836fe87 Upgrade css. 2025-02-09 15:59:26 +01:00
retoor 661eba7161 Upgrade css. 2025-02-09 15:56:19 +01:00
retoor a6555dc069 Upgrade css. 2025-02-09 15:53:45 +01:00
retoor cef83aefe7 Upgrade css. 2025-02-09 15:46:02 +01:00
retoor dc2a31abee Updated web.html. 2025-02-09 14:58:26 +01:00
retoor 83121f7fa9 Updated style. 2025-02-09 13:19:54 +01:00
retoor bfca2bdf73 So smooth. 2025-02-09 13:07:38 +01:00
retoor ecb77cf361 So smooth. 2025-02-09 13:06:12 +01:00
retoor feb5234b3b Progress. 2025-02-09 12:44:51 +01:00
retoor e7cd397e0f Update. 2025-02-09 12:35:38 +01:00
retoor 78f9679f30 Bugfixes. 2025-02-09 07:54:46 +01:00
retoor a3cec5bce0 Branding. 2025-02-09 02:25:06 +01:00
retoor e2a8efe5ca Updated sql security. 2025-02-09 02:07:29 +01:00
retoor a42c2bdf5d Added search user. 2025-02-09 01:42:50 +01:00
retoor afa40ada77 No select. 2025-02-09 01:42:34 +01:00
retoor bda5cfd52d No select. 2025-02-09 01:38:01 +01:00
retoor ad4847a78e No select. 2025-02-09 01:34:02 +01:00
retoor b169fa4792 No select. 2025-02-09 01:31:34 +01:00
retoor 06b539b884 Fixed.. 2025-02-08 23:48:42 +01:00
retoor a8fea31a32 Fixed.. 2025-02-08 22:03:26 +01:00
retoor 5154811b29 CSS Fixes. 2025-02-08 21:54:46 +01:00
retoor 60ca3ec791 Added search form. 2025-02-08 17:59:38 +01:00
retoor 49eb76dc8b Added search form. 2025-02-08 17:33:02 +01:00
retoor d7b943dc8c Changes. 2025-02-08 17:31:03 +01:00
retoor 8d0d709e18 Fix template. 2025-02-08 17:14:37 +01:00
retoor fcb05903f3 Fix template. 2025-02-08 17:09:05 +01:00
retoor f291c0f2e4 Fix. 2025-02-08 17:07:04 +01:00
retoor 7ca2bc5776 Removed double sockets. 2025-02-07 19:45:05 +01:00
retoor 9840c8eb03 Applied emoticons. 2025-02-07 19:42:03 +01:00
retoor 51f1b1d86e Changed markdown. 2025-02-07 18:21:41 +01:00
retoor cfa2af61b8 Changes formatter. 2025-02-07 18:09:15 +01:00
retoor a301e2c5bf Changes formatter. 2025-02-07 18:06:48 +01:00
retoor d4aaa2d66b Changes formatter. 2025-02-07 18:02:10 +01:00
retoor 386d9c3aae Fixed markdown. 2025-02-07 17:49:29 +01:00
retoor 203314b209 Updated False if failed login. 2025-02-06 17:31:14 +01:00
retoor b6185a95f3 Added :snek1: 2025-02-05 19:11:11 +01:00
retoor 6f9adfe67f Drive service. 2025-02-04 23:38:13 +01:00
retoor 084f8dba20 Heavy repair. 2025-02-03 21:15:18 +01:00
retoor f395d16173 Useless button + wrapping 2025-02-03 20:46:41 +01:00
retoor b48a901e33 Non working upload button. 2025-02-03 20:45:29 +01:00
retoor fe707dca4e Back to default. 2025-02-03 01:30:43 +01:00
retoor f4a5536dcf Added highlight. 2025-02-03 01:28:48 +01:00
retoor 079187e1b4 Added highlight. 2025-02-03 01:27:21 +01:00
retoor 83cc0f6137 Added highlight. 2025-02-03 01:24:09 +01:00
retoor 38a24e9a12 Updated CSS. 2025-02-03 00:10:09 +01:00
retoor 23c8ebca73 Updated CSS. 2025-02-03 00:08:17 +01:00
retoor 3ae43c84e7 Wrapped highlight.css 2025-02-03 00:07:33 +01:00
retoor 7d750db1f8 Fixed own notification issue. 2025-02-02 23:21:43 +01:00
retoor 99fc9118b3 Fixed audio notification 2025-02-02 23:14:00 +01:00
retoor a06e3f404a highlight 2025-02-01 19:41:38 +01:00
retoor 98d89dbc5f highlight 2025-02-01 19:38:35 +01:00
retoor e0ed4491b4 Added highlight. 2025-02-01 19:35:27 +01:00
retoor feeb94c9cf Removed Z 2025-02-01 17:39:57 +01:00
retoor 928969b8b6 Added emoji's 2025-02-01 05:41:03 +01:00
retoor 4185bb3a69 Changed validation. 2025-02-01 00:01:23 +01:00
retoor 0c68c4e622 Fixed autoescape. 2025-01-31 22:07:01 +01:00
retoor f6f9968430 Word break 2025-01-31 19:47:03 +01:00
retoor 3796c7c547 Word break 2025-01-31 19:46:02 +01:00
retoor 663ab41510 Word break 2025-01-31 19:36:59 +01:00
retoor ae5fffe5e0 Word break 2025-01-31 19:33:18 +01:00
retoor 1999a6c8d8 Word break 2025-01-31 19:24:50 +01:00
retoor 7526bcc816 Word break 2025-01-31 19:17:40 +01:00
retoor efe12644ed Unbuffered. 2025-01-31 13:47:56 +01:00
retoor cfd3e7881e Unbuffered. 2025-01-31 13:45:41 +01:00
retoor 495543144d Unbuffered. 2025-01-31 13:43:01 +01:00
retoor 2ae2e8450c Unbuffered. 2025-01-31 13:42:12 +01:00
retoor 88749ce05c Unbuffered. 2025-01-31 13:39:48 +01:00
retoor 10c7232a8f Unbuffered. 2025-01-31 12:57:12 +01:00
retoor 8f502af84e Unbuffered. 2025-01-31 12:52:56 +01:00
retoor 010f3b03a0 Unbuffered. 2025-01-31 12:52:22 +01:00
retoor bfdfa6c8bb Unbuffered. 2025-01-31 12:51:47 +01:00
retoor 0a70e80668 Unbuffered. 2025-01-31 12:49:47 +01:00
retoor cc3b896d2c Unbuffered. 2025-01-31 12:45:42 +01:00
retoor 780c178d95 Unbuffered. 2025-01-31 12:43:38 +01:00
retoor 5fd03efc30 Unbuffered. 2025-01-31 12:38:58 +01:00
retoor c6f4393166 Unbuffered. 2025-01-31 12:38:21 +01:00
retoor 312b9eeeca Unbuffered. 2025-01-31 12:34:06 +01:00
retoor 1c53a90e00 Unbuffered. 2025-01-31 12:31:22 +01:00
retoor 4de93489ef Unbuffered. 2025-01-31 12:21:53 +01:00
retoor 8eff6dd6cb Unbuffered. 2025-01-31 12:17:10 +01:00
retoor 15de277a5b drive. 2025-01-31 12:06:42 +01:00
retoor b06a10f6ec Added traceback 2025-01-31 12:06:23 +01:00
retoor 03e9003969 Update. 2025-01-29 23:20:35 +01:00
retoor 99cea506de Username support. 2025-01-29 22:15:25 +01:00
retoor a8e3ad1af9 Update. 2025-01-29 19:47:38 +01:00
retoor 5c4c579389 Update. 2025-01-29 19:17:01 +01:00
retoor 3d6e1d2e94 Update. 2025-01-29 19:13:41 +01:00
retoor 20d8d27f03 Update. 2025-01-29 18:58:38 +01:00
retoor 5b88350ff2 Update. 2025-01-29 18:56:28 +01:00
retoor bca39a612c Update. 2025-01-29 18:12:22 +01:00
retoor f69586ccf7 Deleted pyc 2025-01-29 16:19:53 +00:00
retoor 4fbfe90a13 Added docks. 2025-01-29 17:17:27 +01:00
retoor 75cb7605cd Added docks. 2025-01-29 17:15:54 +01:00
retoor 3be25285f4 Added docks. 2025-01-29 17:12:25 +01:00
retoor af399e3b72 Added docks. 2025-01-29 17:08:40 +01:00
retoor 9e89e27c66 Added docks. 2025-01-29 16:47:25 +01:00
retoor 80f1bbc05e Added docks. 2025-01-29 16:47:20 +01:00
retoor 82de0f3044 Added docks. 2025-01-29 16:42:12 +01:00
retoor b562d17167 Added docks. 2025-01-29 16:41:34 +01:00
retoor f9fed90e86 Move back 2025-01-29 16:37:16 +01:00
retoor d7c003c409 temporary move 2025-01-29 16:36:18 +01:00
retoor 561a915e30 Message 2025-01-29 16:31:06 +01:00
retoor 03c72e85f7 Markdown. 2025-01-29 16:30:54 +01:00
retoor 1f5dc57d6f Install button. 2025-01-29 06:45:26 +01:00
retoor 3e4b6b0062 Install button. 2025-01-29 06:45:14 +01:00
retoor 438fad3014 Install button. 2025-01-29 06:43:42 +01:00
retoor 030942db09 New padding. 2025-01-29 02:53:51 +01:00
retoor 4442f75ec5 New padding. 2025-01-29 02:52:53 +01:00
retoor 5f3dac8bc6 New padding. 2025-01-29 02:48:16 +01:00
retoor c558dc2d79 New padding. 2025-01-29 02:47:00 +01:00
retoor 931aae5134 New padding. 2025-01-29 02:45:18 +01:00
retoor 0e821f8b58 New padding. 2025-01-29 01:06:28 +01:00
retoor 75ec590be5 New padding. 2025-01-29 01:04:54 +01:00
retoor 41927b7ef4 New padding. 2025-01-29 00:55:17 +01:00
retoor 16afbb4e15 New padding. 2025-01-29 00:48:21 +01:00
retoor 9f652ece1b New padding. 2025-01-29 00:46:11 +01:00
retoor 93b2f6cc41 New padding. 2025-01-29 00:35:21 +01:00
retoor 284d38096c New padding. 2025-01-29 00:33:11 +01:00
retoor 84e5bac1b9 New padding. 2025-01-29 00:33:00 +01:00
retoor 9e94210bc3 New padding. 2025-01-28 21:54:53 +01:00
retoor d69c75c619 New padding. 2025-01-28 21:43:48 +01:00
retoor 4da635502b Updated manifest. 2025-01-28 21:01:51 +01:00
retoor 7d05bd9da4 Favicon. 2025-01-28 20:52:37 +01:00
retoor b2ca373081 Reconnector. 2025-01-28 20:41:24 +01:00
retoor 14c59ba5c0 Added notification sound. 2025-01-28 17:37:10 +01:00
retoor 5aee606d5d Added notification sound. 2025-01-28 17:24:10 +01:00
retoor 4f1a48c197 Added schedule. 2025-01-28 17:11:30 +01:00
retoor 99d335ac24 Added schedule. 2025-01-28 17:08:18 +01:00
retoor da72a15068 Removed pyc files. 2025-01-28 15:52:13 +01:00
retoor d93d48ef7e Added 30 limit on all tables. 2025-01-28 15:32:23 +01:00
retoor 01d8093e72 Added 30 limit on all tables. 2025-01-28 15:28:43 +01:00
retoor 8e825a90c6 Deleted pycache. 2025-01-27 05:12:02 +01:00
retoor f3d12a257e Update. 2025-01-27 03:48:53 +01:00
retoor 374db23669 Update. 2025-01-27 03:38:46 +01:00
retoor 095e30a92f Update. 2025-01-27 03:37:04 +01:00
retoor 26210f8c09 Update. 2025-01-27 03:16:44 +01:00
retoor 188a1e6178 Update. 2025-01-27 03:04:43 +01:00
retoor 2a3e225e1d Update. 2025-01-27 02:58:28 +01:00
retoor 4f71f74574 Persistance. 2025-01-27 02:57:51 +01:00
retoor aec9ffd1a1 Persistance. 2025-01-27 01:06:13 +01:00
retoor 87895a72d3 Progress. 2025-01-27 00:56:16 +01:00
retoor 36c69eb8bb Progress. 2025-01-27 00:56:06 +01:00
retoor fb7cb35921 Update. 2025-01-26 22:54:29 +01:00
retoor 4ae846cf8b Update. 2025-01-26 22:51:51 +01:00
retoor 4c601e8333 Format. 2025-01-26 22:49:37 +01:00
retoor 488afdcc74 Progress. 2025-01-26 22:48:58 +01:00
retoor f25feeeca3 Formatting. 2025-01-25 22:28:33 +01:00
retoor b4f9ff2c62 Mappers and models. 2025-01-25 22:24:44 +01:00
retoor bb6bcf41d1 Progress. 2025-01-25 05:50:23 +01:00
retoor 12ca8e4296 Format. 2025-01-25 03:47:16 +01:00
retoor 352d2deb12 Added status 2025-01-25 03:46:53 +01:00
retoor 5c69e14d7c Added session support. 2025-01-25 03:46:33 +01:00
retoor dae877113c Format. 2025-01-24 23:42:24 +01:00
retoor b56371994f Added docs. 2025-01-24 23:41:54 +01:00
retoor 9b93403a93 Formatting. 2025-01-24 23:35:44 +01:00
retoor 18b76ebd5e Progress. 2025-01-24 23:33:36 +01:00
retoor 2ba55f692d Finished register. 2025-01-24 21:19:03 +01:00
retoor be9489f939 Docs. 2025-01-24 16:43:57 +01:00
retoor aecd9f844e Docs. 2025-01-24 16:34:02 +01:00
retoor 8486c22c32 Disabled cache. 2025-01-24 16:33:27 +01:00
retoor 21ab5628b0 Caching. 2025-01-24 16:09:10 +01:00
retoor c1eeacc0b4 Caching. 2025-01-24 16:08:56 +01:00
retoor 6ba6121988 CSS. 2025-01-24 15:20:35 +01:00
retoor 757b67b78c CSS. 2025-01-24 14:47:19 +01:00
retoor bda93e354f Complete system. 2025-01-24 14:16:52 +01:00
retoor 0271e3f971 Complete system. 2025-01-24 14:15:55 +01:00
retoor 4b48485bcc Complete system. 2025-01-24 14:05:47 +01:00
retoor d20079f3ed Complete system. 2025-01-24 14:00:10 +01:00
retoor ba83922660 Progress. 2025-01-24 03:29:54 +01:00
retoor 2e3b85d7f7 Updated compose.yml 2025-01-18 12:23:23 +00:00
retoor a7446d1314 Progress. 2025-01-18 13:21:38 +01:00
retoor 46a27405ae Initial commit. 2025-01-17 23:09:46 +01:00
retoor 66f8942936 Initial commit. 2025-01-17 23:06:17 +01:00
274 changed files with 3917 additions and 17077 deletions
-290
View File
@@ -1,290 +0,0 @@
# Changelog
## Version 1.36.0 - 2026-02-14
Increases the websocket heartbeat interval to reduce network overhead and improve connection efficiency. Loads subscriptions asynchronously to enhance responsiveness during data retrieval.
**Changes:** 1 files, 29 lines
**Languages:** Python (29 lines)
## Version 1.35.0 - 2026-01-31
update py files
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.34.0 - 2026-01-31
update css, html, py files
**Changes:** 44 files, 3802 lines
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
## Version 1.33.0 - 2026-01-24
Modularize RPC action handlers by splitting the monolithic rpc.py file into separate components for authentication, channels, containers, databases, mentions, messages, and miscellaneous actions. This improves code organization and facilitates easier maintenance and extension of RPC functionalities.
**Changes:** 13 files, 2753 lines
**Languages:** Python (2753 lines)
## Version 1.32.0 - 2026-01-24
Adds mention functionality to the chat system, enabling users to mention other users with backend models and services handling the logic. Updates the frontend with navigation for mentions, including modified chat input and new mention navigation components.
**Changes:** 13 files, 438 lines
**Languages:** CSS (75 lines), HTML (1 lines), JavaScript (125 lines), Python (237 lines)
## Version 1.31.0 - 2026-01-24
Reduces the maximum number of workers for channel message processing to 1, limiting concurrency to improve performance in resource-constrained environments.
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.30.0 - 2026-01-17
Increases the maximum number of workers for processing channel messages to 30, enabling better handling of concurrent message loads.
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.29.0 - 2026-01-17
Updates the site's CSS and HTML to refine the visual layout and styling of the index page. These changes enhance readability and user interface consistency without altering core functionality.
**Changes:** 2 files, 297 lines
**Languages:** CSS (293 lines), HTML (4 lines)
## Version 1.28.0 - 2026-01-17
Adds a documentation hub with pages on architecture, design, API, bots, and contributions. Increases the maximum workers in the channel message service to 10 for improved performance and updates navigation links in the about and index pages.
**Changes:** 11 files, 2755 lines
**Languages:** HTML (2719 lines), Python (36 lines)
## Version 1.27.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.26.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.25.0 - 2026-01-16
update html files
**Changes:** 1 files, 2 lines
**Languages:** HTML (2 lines)
## Version 1.24.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 60 lines
**Languages:** CSS (44 lines), JavaScript (16 lines)
## Version 1.23.0 - 2026-01-03
update py files
**Changes:** 1 files, 30 lines
**Languages:** Python (30 lines)
## Version 1.22.0 - 2026-01-03
update py files
**Changes:** 1 files, 8 lines
**Languages:** Python (8 lines)
## Version 1.21.0 - 2026-01-03
update py files
**Changes:** 1 files, 2 lines
**Languages:** Python (2 lines)
## Version 1.20.0 - 2026-01-03
update css files
**Changes:** 2 files, 25 lines
**Languages:** CSS (25 lines)
## Version 1.19.0 - 2026-01-03
update css, html, js files
**Changes:** 4 files, 311 lines
**Languages:** CSS (137 lines), HTML (3 lines), JavaScript (171 lines)
## Version 1.18.0 - 2026-01-03
update html, js files
**Changes:** 2 files, 271 lines
**Languages:** HTML (39 lines), JavaScript (232 lines)
## Version 1.17.0 - 2026-01-03
update css, js, py files
**Changes:** 4 files, 17 lines
**Languages:** CSS (11 lines), JavaScript (2 lines), Python (4 lines)
## Version 1.16.0 - 2026-01-03
update css, js files
**Changes:** 3 files, 15 lines
**Languages:** CSS (11 lines), JavaScript (4 lines)
## Version 1.15.0 - 2026-01-03
update css files
**Changes:** 1 files, 19 lines
**Languages:** CSS (19 lines)
## Version 1.14.0 - 2025-12-26
Users can now create, configure settings for, and delete channels through dedicated dialog interfaces. Developers access new RPC methods to support these channel management operations.
**Changes:** 9 files, 801 lines
**Languages:** CSS (127 lines), HTML (517 lines), Python (157 lines)
## Version 1.13.0 - 2025-12-24
Improves performance in the balancer, socket service, and cache by removing async locks. Adds database connection injection for testing in the app and updates pytest configuration.
**Changes:** 5 files, 291 lines
**Languages:** Other (6 lines), Python (285 lines)
## Version 1.12.0 - 2025-12-21
The socket service enhances concurrency and performance through improved locking and asynchronous handling. The RPC view renames the scheduled list to tasks for clearer API terminology.
**Changes:** 2 files, 243 lines
**Languages:** Python (243 lines)
## Version 1.11.0 - 2025-12-19
Adds the ability to configure whether user registration is open or closed via system settings. Administrators can now toggle registration openness to control access to the registration form.
**Changes:** 5 files, 262 lines
**Languages:** HTML (185 lines), Python (77 lines)
## Version 1.10.0 - 2025-12-19
Users must now receive an invitation to register for an account, as open registration is disabled.
**Changes:** 1 files, 6 lines
**Languages:** HTML (6 lines)
## Version 1.9.0 - 2025-12-18
Adds a debug logging option to the serve command for enhanced troubleshooting. Improves error handling across the application and corrects a typo in the ip2location middleware.
**Changes:** 148 files, 1047 lines
**Languages:** JavaScript (299 lines), Python (748 lines)
## Version 1.8.0 - 2025-12-18
The socket service now handles errors more robustly and prevents crashes through improved safety checks. Socket methods support better concurrency and provide enhanced logging for developers.
**Changes:** 4 files, 2279 lines
**Languages:** JavaScript (592 lines), Python (1687 lines)
## Version 1.7.0 - 2025-12-17
Fixes socket cleanup in the websocket handler to prevent resource leaks and improve connection stability.
**Changes:** 1 files, 29 lines
**Languages:** Python (29 lines)
## Version 1.6.0 - 2025-12-17
Removes presence debounce to make user departures instant. Simplifies websocket connection and error handling in RPC views for improved reliability.
**Changes:** 2 files, 98 lines
**Languages:** Python (98 lines)
## Version 1.5.0 - 2025-12-17
remove umami analytics script
**Changes:** 1 files, 1 lines
**Languages:** HTML (1 lines)
## Version 1.4.0 - 2025-12-17
Updates the socket service to improve connection stability and error handling.
**Changes:** 1 files, 4 lines
**Languages:** Python (4 lines)
## Version 1.3.0 - 2025-12-17
Users now receive notifications when other users join or depart the application. Departure notifications are debounced to reduce the frequency of rapid successive alerts.
**Changes:** 5 files, 418 lines
**Languages:** HTML (1 lines), JavaScript (259 lines), Python (158 lines)
## Version 1.2.0 - 2025-12-17
Removes Umami analytics integration, eliminating user tracking functionality. Developers must handle analytics separately if needed.
**Changes:** 2 files, 2 lines
**Languages:** HTML (1 lines), Python (1 lines)
## Version 1.1.0 - 2025-12-17
Fixes potential errors in forum message handling by adding a null check for the star field, preventing crashes when the field is missing. Updates the message list display to handle starred messages more reliably.
**Changes:** 3 files, 10 lines
**Languages:** JavaScript (2 lines), Python (8 lines)
+2 -11
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.36.0"
version = "1.0.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
@@ -39,9 +39,7 @@ dependencies = [
"Pillow",
"pillow-heif",
"IP2Location",
"bleach",
"sentry-sdk",
"bcrypt"
"bleach"
]
[tool.setuptools.packages.find]
@@ -52,10 +50,3 @@ where = ["src"] # <-- this changed
[project.scripts]
snek = "snek.__main__:main"
[project.optional-dependencies]
test = [
"pytest",
"pytest-asyncio",
"pytest-aiohttp"
]
-15
View File
@@ -1,15 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--strict-markers
--disable-warnings
--tb=short
-v
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
asyncio_mode = auto
-55
View File
@@ -1,55 +0,0 @@
# Project Quality Review: Snek
## Overview
The Snek project is a comprehensive web-based application, functioning as a collaborative platform or chat system with extensive features including user management, channels, repositories, containers, real-time communication via WebSockets, Docker integration, Git support, and more. It leverages Python (primarily with aiohttp for the backend) and JavaScript for the frontend. The codebase is organized into modules such as app, mapper, model, service, view, system, and static assets. This review is based on a literal examination of every Python file in `src/snek/*` recursively, as their full contents have been provided.
## Strengths
- **Modular Architecture**: The code exhibits strong separation of concerns with dedicated modules for mappers (data access), models (data structures), services (business logic), views (HTTP/WebSocket handlers), and system utilities. This promotes maintainability and scalability.
- **Asynchronous Support**: Extensive use of async/await throughout (e.g., in `src/snek/app.py`, `src/snek/service/socket.py`, `src/snek/view/rpc.py`), effectively handling I/O-bound operations like WebSockets, database queries, and external API calls.
- **Feature-Rich**: Supports a wide array of features, including user authentication, file uploads, terminal emulation, Docker container management, Git repositories, WebDAV, SSH, forums, push notifications, and avatar generation. Integration with external tools like Docker and Git is notable.
- **Frontend Components**: Custom JavaScript components (e.g., in `src/snek/static/njet.js`) provide a modern, component-based UI with event handling and REST clients.
- **Caching and Utilities**: Robust caching implementation (e.g., in `src/snek/system/cache.py`) with LRU eviction, and utility services (e.g., `src/snek/service/util.py`) enhance performance and reusability.
- **Model and Field System**: A sophisticated model system (e.g., in `src/snek/system/model.py`) with typed fields (e.g., UUIDField, CreatedField) ensures data integrity and validation.
- **Extensibility**: The codebase includes hooks for extensions (e.g., Jinja2 extensions in `src/snek/system/markdown.py`, `src/snek/system/template.py`) and sub-applications (e.g., forum in `src/snek/forum.py`).
- **Error Handling in Places**: Some areas show good error handling (e.g., try-except in `src/snek/service/socket.py`, `src/snek/view/rpc.py`), though not universal.
## Weaknesses
- **Syntax Errors**:
- In `src/snek/research/serpentarium.py`, line 25: `self.setattr(self, "db", self.get)` `setattr` is misspelled; it should be `setattr`. Additionally, the line ends with `)`, which is misplaced and causes a syntax error. Line 26: `self.setattr(self, "db", self.set)` similar misspelling and incorrect assignment (self.set is not defined).
- In `src/snek/sync.py`, line 25: `self.setattr(self, "db", self.get)` `setattr` misspelled and misplaced `)`. Line 26: `self.setattr(self, "db", self.set)` same issues. Line 27: `super()` called without arguments, which may fail in classes with multiple inheritance or if arguments are expected.
- **Security Concerns**:
- **Injection Vulnerabilities**: Raw SQL queries without parameterization (e.g., in `src/snek/service/channel.py`, `src/snek/service/user.py`, `src/snek/view/rpc.py`) risk SQL injection. For example, in `src/snek/service/channel.py`, queries like `f"SELECT ... WHERE channel_uid=:channel_uid {history_start_filter}"` use string formatting, but not all are parameterized.
- **Authentication Gaps**: Some WebSocket and RPC endpoints lack visible authentication checks (e.g., in `src/snek/view/rpc.py`, methods like `echo` and `query` don't enforce login). Basic auth in `src/snek/webdav.py` and `src/snek/sssh.py` is present but hardcoded.
- **Input Validation**: Minimal input sanitization (e.g., in `src/snek/system/form.py`, forms have placeholders but no regex or length checks enforced). User inputs in RPC calls (e.g., `src/snek/view/rpc.py`) are not validated.
- **Hardcoded Secrets**: Database paths (e.g., 'sqlite:///snek.db' in multiple files), keys (e.g., SESSION_KEY in `src/snek/app.py`), and credentials are hardcoded, posing risks if exposed.
- **Privilege Escalation**: Admin checks are inconsistent (e.g., in `src/snek/service/channel.py`, `src/snek/view/rpc.py`), and some operations (e.g., clearing channels) only check `is_admin` without further validation.
- **WebDAV and SSH**: `src/snek/webdav.py` and `src/snek/sssh.py` handle file access but lack rate limiting or detailed permission checks.
- **Code Quality Issues**:
- **Inconsistent Naming and Style**: Mixed camelCase and snake_case (e.g., `set_typing` vs. `generateUniqueId` in JS). Some classes have similar names (e.g., `DatasetWebSocketView` in research files).
- **Lack of Documentation**: Few docstrings or comments (e.g., no docstrings in `src/snek/service/user.py`, `src/snek/view/channel.py`). Methods are often self-explanatory but lack context.
- **Hardcoded Values**: URLs, paths, and constants are hardcoded (e.g., in `src/snek/static/njet.js`, `src/snek/app.py`).
- **Global State and Side Effects**: Use of global variables (e.g., in `src/snek/system/markdown.py`, `src/snek/view/rpc.py`) and mutable defaults can lead to bugs.
- **Performance Issues**: No visible optimization for large datasets (e.g., in `src/snek/service/channel_message.py`, queries could be inefficient). Process pools in `src/snek/service/channel_message.py` are per-message, potentially wasteful.
- **Error Handling Gaps**: Many methods lack try-except (e.g., in `src/snek/system/docker.py`, `src/snek/system/terminal.py`). Exceptions are sometimes caught but not logged properly.
- **Dependencies**: Imports like `dataset`, `git`, `pymongo` (implied) are not version-pinned, risking compatibility issues.
- **Testing Absence**: No visible unit or integration tests in the codebase.
- **Potential Bugs**:
- In `src/snek/research/serpentarium.py`, `self.setattr(self, "db", self.set)` assigns undefined `self.set`.
- In `src/snek/sync.py`, similar assignment issues.
- In `src/snek/view/rpc.py`, `self.user_uid` property assumes `self.view.session.get("uid")`, but no null checks.
- In `src/snek/system/docker.py`, `ComposeFileManager` uses subprocess without full error handling.
- In `src/snek/service/channel_message.py`, executor pools per UID could lead to resource leaks if not cleaned up.
- In `src/snek/forum.py`, event listeners are added but no removal logic is visible.
- **Maintainability**: Large files (e.g., `src/snek/view/rpc.py` is over 1000 lines) and complex methods (e.g., in `src/snek/app.py`) make refactoring hard. Some code duplication (e.g., WebSocket handling in multiple views).
## Recommendations
- **Fix Syntax Errors Immediately**: Correct `setattr` spellings, remove misplaced `)`, and fix `super()` calls in `src/snek/research/serpentarium.py` and `src/snek/sync.py` to prevent runtime failures.
- **Enhance Security**: Implement parameterized queries, add input validation (e.g., using regex in `src/snek/system/form.py`), enforce authentication in all endpoints, and use environment variables for secrets.
- **Improve Code Quality**: Add docstrings, comments, and consistent naming. Refactor large methods and remove hardcoded values.
- **Add Error Handling and Testing**: Wrap risky operations in try-except, log errors, and introduce unit tests (e.g., using pytest).
- **Optimize Performance**: Review query efficiency, add indexing, and manage resources (e.g., executor pools).
- **Address Bugs**: Fix undefined assignments and add null checks.
- **General**: Pin dependencies, review for race conditions in async code, and consider code reviews or linters (e.g., flake8, mypy).
## Grade
B- (Solid foundation with good architecture and features, but critical syntax errors, security vulnerabilities, and quality issues require immediate attention to avoid production risks.)
+1 -4
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
"""
MIT License
@@ -54,8 +52,7 @@ def load_env(file_path: str = '.env') -> None:
# Skip lines without '='
if '=' not in line:
continue
if line.startswith('export '):
line = line[7:]
# Split into key and value at the first '='
key, value = line.split('=', 1)
# Set environment variable
os.environ[key.strip()] = value.strip()
+6 -25
View File
@@ -1,18 +1,12 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import shutil
import sqlite3
import asyncio
import click
from aiohttp import web
from IPython import start_ipython
from snek.shell import Shell
from snek.app import Application
from snek.shell import Shell
logging.basicConfig(level=logging.INFO)
@click.group()
@@ -111,16 +105,9 @@ def init(db_path, source):
show_default=True,
help="Database path for the application",
)
@click.option(
"--debug",
is_flag=True,
default=False,
help="Enable debug logging",
)
def serve(port, host, db_path, debug):
if debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("snek").setLevel(logging.DEBUG)
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)
@@ -135,12 +122,6 @@ def shell(db_path):
Shell(db_path).run()
def main():
try:
import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError:
print("Could not import sentry_sdk")
cli()
+72 -137
View File
@@ -1,14 +1,17 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import pathlib
import ssl
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
import signal
from datetime import datetime
from contextlib import asynccontextmanager
from snek import snode
from snek.view.threads import ThreadsView
logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor
from ipaddress import ip_address
import IP2Location
@@ -22,16 +25,15 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from jinja2 import FileSystemLoader
from snek import snode
from snek.mapper import get_mappers
from snek.service import get_services
from snek.sgit import GitApplication
from snek.sssh import start_ssh_server
from snek.system import http
from snek.system.cache import Cache
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.config import config
from snek.system.profiler import profiler_handler
from snek.system.template import (
EmojiExtension,
@@ -39,15 +41,12 @@ from snek.system.template import (
PythonExtension,
sanitize_html,
)
from snek.view.new import NewView
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.site import ArchitectureView, DesignView, ApiView, BotsView, ContributeView
from snek.view.drive import DriveApiView, DriveView
from snek.view.channel import ChannelDriveApiView
from snek.view.container import ContainerView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.logout import LogoutView
@@ -56,7 +55,7 @@ from snek.view.register import RegisterView
from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView
from snek.view.threads import ThreadsView
from snek.view.container import ContainerView
from snek.view.settings.containers import (
ContainersCreateView,
ContainersDeleteView,
@@ -71,37 +70,7 @@ from snek.view.settings.repositories import (
RepositoriesIndexView,
RepositoriesUpdateView,
)
from snek.view.settings.profile_pages import (
ProfilePagesView,
ProfilePageCreateView,
ProfilePageEditView,
ProfilePageDeleteView,
)
from snek.view.profile_page import ProfilePageView
from snek.view.stats import StatsView
from snek.view.admin import (
AdminIndexView,
AdminUsersIndexView,
AdminUserEditView,
AdminUserBanView,
AdminChannelsIndexView,
AdminChannelMembersView,
AdminChannelEditView,
AdminChannelClearView,
AdminChannelDeleteView,
AdminMessagesIndexView,
AdminForumsIndexView,
AdminThreadsView,
AdminPostsView,
AdminFilesIndexView,
AdminDriveItemsView,
AdminRepositoriesView,
AdminNotificationsIndexView,
AdminNotificationCreateView,
AdminSystemIndexView,
AdminKVView,
AdminPushView,
)
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
@@ -109,12 +78,9 @@ from snek.view.user import UserView
from snek.view.web import WebView
from snek.webdav import WebdavApplication
from snek.forum import setup_forum
from snek.system.template import whitelist_attributes
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@web.middleware
@@ -127,22 +93,21 @@ async def session_middleware(request, handler):
@web.middleware
async def ip2location_middleware(request, handler):
response = await handler(request)
return response
ip = request.headers.get("X-Forwarded-For", request.remote)
try:
ipaddr = ip_address(ip)
if ipaddr.is_private:
return response
except ValueError:
ipaddress = ip_address(ip)
if ipaddress.is_private:
return response
if not request.session.get("uid"):
if not request.app.session.get("uid"):
return response
user = await request.app.services.user.get(uid=request.session.get("uid"))
user = await request.app.services.user.get(uid=request.app.session.get("uid"))
if not user:
return response
location = request.app.ip2location.get_all(ip)
location = request.app.ip2location.get(ip)
user["city"]
if user["city"] != location.city:
user["country_long"] = location.country_long
user["country_short"] = location.country_short
user["country_long"] = location.country
user["country_short"] = locaion.country_short
user["city"] = location.city
user["region"] = location.region
user["latitude"] = location.latitude
@@ -155,35 +120,26 @@ async def ip2location_middleware(request, handler):
@web.middleware
async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"):
# Redirect to the same path with a trailing slash
raise web.HTTPFound(request.path + "/")
return await handler(request)
class Application(BaseApplication):
async def create_default_forum(self, app):
forums = [f async for f in self.services.forum.find(is_active=True)]
if not forums:
# Find admin user to be the creator
admin_user = await self.services.user.get(is_admin=True)
if admin_user:
await self.services.forum.create_forum(
name="General Discussion",
description="A place for general discussion.",
created_by_uid=admin_user["uid"],
)
def __init__(self, *args, db_connection=None, **kwargs):
def __init__(self, *args, **kwargs):
middlewares = [
stats_middleware,
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
csp_middleware,
]
self._test_db = db_connection
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares,
template_path=self.template_path,
client_max_size=1024 * 1024 * 1024 * 5,
client_max_size=1024 * 1024 * 1024 * 5 * args,
**kwargs,
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
@@ -204,32 +160,26 @@ class Application(BaseApplication):
self.sync_service = None
self.executor = None
self.cache = Cache(self)
self.config = config
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
)
self.on_startup.append(self.prepare_stats)
self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
self.on_startup.append(self.create_default_forum)
async def prepare_stats(self, app):
app['stats'] = create_stats_structure()
print("Stats prepared", flush=True)
@property
def db(self):
if self._test_db is not None:
return self._test_db
return self._db
@db.setter
def db(self, value):
self._db = value
@property
def uptime_seconds(self):
@@ -271,8 +221,13 @@ class Application(BaseApplication):
asyncio.create_task(app.ssh_server.wait_closed())
async def prepare_asyncio(self, app):
# app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
#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):
await self.tasks.put(task)
@@ -285,8 +240,9 @@ class Application(BaseApplication):
await task
self.tasks.task_done()
except Exception as ex:
logger.error(f"Task runner error: {ex}")
print(ex)
self.db.commit()
async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL")
@@ -299,8 +255,8 @@ class Application(BaseApplication):
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
except Exception as ex:
logger.warning(f"Index creation error: {ex}")
except:
pass
await self.services.drive.prepare_all()
self.loop.create_task(self.task_runner())
@@ -313,8 +269,6 @@ class Application(BaseApplication):
name="static",
show_index=True,
)
self.router.add_view("/new.html", NewView)
self.router.add_view("/profiler.html", profiler_handler)
self.router.add_view("/container/sock/{channel_uid}.json", ContainerView)
self.router.add_view("/about.html", AboutHTMLView)
@@ -323,11 +277,6 @@ class Application(BaseApplication):
self.router.add_view("/logout.html", LogoutView)
self.router.add_view("/docs.html", DocsHTMLView)
self.router.add_view("/docs.md", DocsMDView)
self.router.add_view("/architecture.html", ArchitectureView)
self.router.add_view("/design.html", DesignView)
self.router.add_view("/api.html", ApiView)
self.router.add_view("/bots.html", BotsView)
self.router.add_view("/contribute.html", ContributeView)
self.router.add_view("/status.json", StatusView)
self.router.add_view("/settings/index.html", SettingsIndexView)
self.router.add_view("/settings/profile.html", SettingsProfileView)
@@ -338,6 +287,9 @@ class Application(BaseApplication):
self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/drive.bin", UploadView)
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView)
@@ -345,6 +297,12 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view(
"/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
)
self.router.add_view(
"/channel/{channel_uid}/drive.json", ChannelDriveApiView
)
self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
)
@@ -357,10 +315,10 @@ class Application(BaseApplication):
self.router.add_view("/terminal.html", TerminalView)
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_get("/stats.html", stats_handler)
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/user/{user_uid}/{slug}.html", ProfilePageView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view(
"/repository/{username}/{repository}/{path:.*}", RepositoryView
@@ -377,14 +335,6 @@ class Application(BaseApplication):
"/settings/repositories/repository/{name}/delete.html",
RepositoriesDeleteView,
)
self.router.add_view("/settings/profile_pages/index.html", ProfilePagesView)
self.router.add_view("/settings/profile_pages/create.html", ProfilePageCreateView)
self.router.add_view(
"/settings/profile_pages/{page_uid}/edit.html", ProfilePageEditView
)
self.router.add_view(
"/settings/profile_pages/{page_uid}/delete.html", ProfilePageDeleteView
)
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view(
@@ -393,35 +343,16 @@ class Application(BaseApplication):
self.router.add_view(
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
)
self.router.add_view("/admin/index.html", AdminIndexView)
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
self.router.add_view("/admin/system/kv.html", AdminKVView)
self.router.add_view("/admin/system/push.html", AdminPushView)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await whitelist_attributes(
self.render_template("test.html", request, context={"name": "retoor"})
)
@@ -438,13 +369,12 @@ class Application(BaseApplication):
body=path.read_bytes(), headers={"Content-Type": "image/png"}
)
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
start_time = time.perf_counter()
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
context["config"] = self.config
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
@@ -482,23 +412,27 @@ class Application(BaseApplication):
request.session.get("uid")
)
self.template_path.joinpath(template)
await self.services.user.get_template_path(request.session.get("uid"))
self.original_loader = self.jinja2_env.loader
self.jinja2_env.loader = await self.get_user_template_loader(
request.session.get("uid")
)
try:
context["nonce"] = request["csp_nonce"]
except KeyError:
context["nonce"] = "?"
context["nonce"] = request['csp_nonce']
except:
context['nonce'] = '?'
rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader
end_time = time.perf_counter()
logger.debug(f"render_template took {end_time - start_time:.4f} seconds")
# rendered.text = whitelist_attributes(rendered.text)
# rendered.headers['Content-Lenght'] = len(rendered.text)
return rendered
async def static_handler(self, request):
@@ -538,21 +472,22 @@ class Application(BaseApplication):
template_paths.append(self.template_path)
return FileSystemLoader(template_paths)
@asynccontextmanager
async def no_save(self):
stats = {"count": 0}
stats = {
'count': 0
}
async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0])
stats["count"] = stats["count"] + 1
logger.debug(f"save is ignored {stats['count']} times")
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
yield
except Exception as ex:
raised_exception = ex
finally:
+7 -6
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import asyncio
import sys
@@ -9,6 +7,7 @@ class LoadBalancer:
self.backend_ports = backend_ports
self.backend_processes = []
self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock()
async def start_backend_servers(self, port, workers):
for x in range(workers):
@@ -28,9 +27,10 @@ class LoadBalancer:
)
async def handle_client(self, reader, writer):
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
async with self.lock:
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ("127.0.0.1", self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
@@ -55,7 +55,8 @@ class LoadBalancer:
print(f"Error: {e}")
finally:
writer.close()
self.client_counts[server_index] -= 1
async with self.lock:
self.client_counts[server_index] -= 1
async def monitor(self):
while True:
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import pathlib
from aiohttp import web
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import asyncio
from snek.app import app
-3
View File
@@ -1,3 +0,0 @@
# retoor <retoor@molodetz.nl>
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
+22 -2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
@@ -80,7 +78,29 @@ class ForumApplication(aiohttp.web.Application):
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.app import app
application = app
-6
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.mapper.channel import ChannelMapper
@@ -14,9 +12,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.profile_page import ProfilePageMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.mapper.mention import MentionMapper
from snek.system.object import Object
@@ -40,8 +36,6 @@ def get_mappers(app=None):
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
"profile_page": ProfilePageMapper(app=app),
"mention": MentionMapper(app=app),
}
)
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel import ChannelModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.container import Container
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive import DriveModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper
-9
View File
@@ -1,9 +0,0 @@
# retoor <retoor@molodetz.nl>
from snek.model.mention import MentionModel
from snek.system.mapper import BaseMapper
class MentionMapper(BaseMapper):
table_name = "mention"
model_class = MentionModel
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper
-11
View File
@@ -1,11 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.profile_page import ProfilePageModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class ProfilePageMapper(BaseMapper):
table_name = "profile_page"
model_class = ProfilePageModel
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.push_registration import PushRegistrationModel
from snek.system.mapper import BaseMapper
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.repository import RepositoryModel
from snek.system.mapper import BaseMapper
+1 -7
View File
@@ -1,12 +1,6 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.model.user import UserModel
from snek.system.mapper import BaseMapper
logger = logging.getLogger(__name__)
class UserMapper(BaseMapper):
table_name = "user"
@@ -22,5 +16,5 @@ class UserMapper(BaseMapper):
)
]
except Exception as ex:
logger.warning(f"Failed to get admin uids: {ex}")
print(ex)
return []
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.user_property import UserPropertyModel
from snek.system.mapper import BaseMapper
+2 -2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.model.channel import ChannelModel
@@ -46,3 +44,5 @@ def get_models():
def get_model(name):
return get_models()[name]
+3 -4
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
@@ -25,11 +23,12 @@ class ChannelModel(BaseModel):
history_start_filter = f" AND created_at > '{self['history_start']}' "
try:
async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]},
):
return await self.app.services.channel_message.get(uid=model["uid"])
except Exception:
except:
pass
return None
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from snek.model.user import UserModel
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.model import BaseModel, ModelField
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
# models/forum.py
from snek.system.model import BaseModel, ModelField
-11
View File
@@ -1,11 +0,0 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class MentionModel(BaseModel):
message_uid = ModelField(name="message_uid", required=True, kind=str)
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
mentioned_user_uid = ModelField(name="mentioned_user_uid", required=True, kind=str)
author_user_uid = ModelField(name="author_user_uid", required=True, kind=str)
read_at = ModelField(name="read_at", required=False, kind=str)
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-14
View File
@@ -1,14 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import BaseModel, ModelField
logger = logging.getLogger(__name__)
class ProfilePageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str)
slug = ModelField(name="slug", required=True, kind=str)
content = ModelField(name="content", required=False, kind=str, value="")
order_index = ModelField(name="order_index", required=True, kind=int, value=0)
is_published = ModelField(name="is_published", required=True, kind=bool, value=True)
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
-4
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
@@ -9,6 +7,4 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
description = ModelField(name="description", required=False, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
+1 -3
View File
@@ -1,9 +1,7 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import BaseModel, ModelField
class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
value = ModelField(name="value", required=True, kind=str)
value = ModelField(name="path", required=True, kind=str)
+201 -17
View File
@@ -1,15 +1,39 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import asyncio
import aiohttp
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
class DatasetMethod:
pass
def __init__(self, dt, name):
self.dt = dt
self.name = name
def __call__(self, *args, **kwargs):
return self.dt.ds.call(
self.dt.name,
self.name,
*args,
**kwargs
)
class DatasetTable:
pass
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
class WebSocketClient2:
@@ -19,40 +43,200 @@ class WebSocketClient2:
self.websocket = None
self.receive_queue = asyncio.Queue()
# Schedule connection setup
if self.loop.is_running():
# Schedule connect in the existing loop
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
else:
# If loop isn't running, connect synchronously
self.loop.run_until_complete(self._connect())
async def _connect(self):
self.websocket = await websockets.connect(self.uri)
# Start listening for messages
asyncio.create_task(self._receive_loop())
async def _receive_loop(self):
try:
async for message in self.websocket:
await self.receive_queue.put(message)
except Exception:
pass # Handle exceptions as needed
def send(self, message: str):
pass
if self.loop.is_running():
# Schedule send in the existing loop
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
else:
# If loop isn't running, run directly
self.loop.run_until_complete(self.websocket.send(message))
def receive(self):
# Wait for a message synchronously
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
return future.result()
def close(self):
pass
if self.websocket:
if self.loop.is_running():
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
else:
self.loop.run_until_complete(self.websocket.close())
class DatasetWrapper:
import websockets
class DatasetWrapper(object):
def __init__(self):
pass
self.ws = WebSocketClient()
def begin(self):
self.call(None, 'begin')
def commit(self):
pass
self.call(None, 'commit')
def __getitem__(self, name):
return DatasetTable(self, name)
def query(self, *args, **kwargs):
pass
return self.call(None, 'query', *args, **kwargs)
def call(self, table, method, *args, **kwargs):
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
#if method in ['find','find_one']:
payload["call_uid"] = str(uuid.uuid4())
self.ws.write(json.dumps(payload))
if payload["call_uid"]:
response = self.ws.read()
return json.loads(response)['result']
return True
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
pass
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
pass
return await self.ws.send_str(msg)
def get(self, key):
pass
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
pass
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
app = web.Application()
view = DatasetWebSocketView()
app.router.add_get('/db', view.handle)
async def run_server():
pass
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', 3131)
await site.start()
print("Server started at http://localhost:8080")
await asyncio.Event().wait()
async def client():
print("x")
d = DatasetWrapper()
print("y")
for x in range(100):
for x in range(100):
if d['test'].insert({"name": "test", "number":x}):
print(".",end="",flush=True)
print("")
print(d['test'].find_one(name="test", order_by="-number"))
print("DONE")
import time
async def main():
await run_server()
import sys
if __name__ == '__main__':
if sys.argv[1] == 'server':
asyncio.run(main())
if sys.argv[1] == 'client':
asyncio.run(client())
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import time
from concurrent.futures import ProcessPoolExecutor
+26 -72
View File
@@ -1,33 +1,32 @@
CREATE TABLE user (
CREATE TABLE IF NOT EXISTS http_access (
id INTEGER NOT NULL,
created TEXT,
path TEXT,
duration FLOAT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL,
city TEXT,
color TEXT,
country_long TEXT,
country_short TEXT,
created_at TEXT,
deleted_at TEXT,
email TEXT,
ip TEXT,
is_admin TEXT,
last_ping TEXT,
latitude TEXT,
longitude TEXT,
nick TEXT,
password TEXT,
region TEXT,
uid TEXT,
updated_at TEXT,
username TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE channel (
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS channel (
id INTEGER NOT NULL,
created_at TEXT,
created_by_uid TEXT,
deleted_at TEXT,
description TEXT,
history_start TEXT,
"index" BIGINT,
is_listed BOOLEAN,
is_private BOOLEAN,
@@ -38,8 +37,8 @@ CREATE TABLE channel (
updated_at TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE channel_member (
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
@@ -55,31 +54,28 @@ CREATE TABLE channel_member (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE channel_message (
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE IF NOT EXISTS broadcast (
id INTEGER NOT NULL,
channel_uid TEXT,
message TEXT,
created_at TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
html TEXT,
is_final BOOLEAN,
message TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE INDEX ix_channel_message_acb69f257bb37684 ON channel_message (is_final, user_uid, channel_uid);
CREATE INDEX ix_channel_message_c6c4cddf281df93d ON channel_message (deleted_at);
CREATE TABLE kv (
id INTEGER NOT NULL,
"key" TEXT,
value TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_kv_a62f2225bf70bfac ON kv ("key");
CREATE TABLE notification (
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
@@ -92,38 +88,11 @@ CREATE TABLE notification (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
CREATE TABLE drive (
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
name TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_drive_e2577dd78b54fe28 ON drive (uid);
CREATE TABLE push_registration (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
endpoint TEXT,
key_auth TEXT,
key_p256dh TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_push_registration_e2577dd78b54fe28 ON push_registration (uid);
CREATE TABLE repository (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
description TEXT,
is_private BIGINT,
name TEXT,
uid TEXT,
@@ -131,19 +100,4 @@ CREATE TABLE repository (
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
CREATE TABLE profile_page (
id INTEGER NOT NULL,
content TEXT,
created_at TEXT,
deleted_at TEXT,
is_published BOOLEAN,
order_index BIGINT,
slug TEXT,
title TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
+1 -9
View File
@@ -1,8 +1,5 @@
# retoor <retoor@molodetz.nl>
import functools
from snek.service.admin import AdminService
from snek.service.channel import ChannelService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.channel_member import ChannelMemberService
@@ -22,8 +19,6 @@ from snek.service.util import UtilService
from snek.system.object import Object
from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
from snek.service.profile_page import ProfilePageService
from snek.service.mention import MentionService
_service_registry = {}
def register_service(name, service_cls):
@@ -45,7 +40,7 @@ def get_services(app):
def get_service(name, app=None):
return get_services(app=app)[name]
# Registering all services
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
@@ -67,7 +62,4 @@ register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)
register_service("mention", MentionService)
register_service("admin", AdminService)
-577
View File
@@ -1,577 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime
from typing import Any
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class AdminService(BaseService):
mapper_name = None
async def _require_admin(self, user_uid: str) -> bool:
user = await self.services.user.get(uid=user_uid)
if not user or not user["is_admin"]:
raise PermissionError("Admin access required")
return True
async def get_dashboard_stats(self) -> dict[str, Any]:
logger.info("Fetching dashboard statistics")
user_count = await self.services.user.count(deleted_at=None)
channel_count = await self.services.channel.count(deleted_at=None)
message_count = await self.services.channel_message.count(deleted_at=None)
forum_count = await self.services.forum.count(deleted_at=None)
thread_count = await self.services.thread.count(deleted_at=None)
post_count = await self.services.post.count(deleted_at=None)
connected_users = await self.services.socket.get_connected_users()
online_count = len(connected_users)
drive_count = await self.services.drive.count(deleted_at=None)
repository_count = await self.services.repository.count(deleted_at=None)
notification_count = await self.services.notification.count(deleted_at=None)
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
return {
"users": user_count,
"channels": channel_count,
"messages": message_count,
"forums": forum_count,
"threads": thread_count,
"posts": post_count,
"online": online_count,
"drives": drive_count,
"repositories": repository_count,
"notifications": notification_count,
}
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
logger.info(f"Fetching recent activity, limit={limit}")
messages = []
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:100] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
logger.debug(f"Fetched {len(messages)} recent messages")
return messages
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
users = []
async for user in self.services.user.find(**filters):
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
continue
users.append({
"uid": user["uid"],
"username": user["username"],
"nick": user["nick"],
"is_admin": user["is_admin"] or False,
"is_banned": user["is_banned"] or False,
"created_at": str(user["created_at"]) if user["created_at"] else None,
"last_online": str(user["last_online"]) if user["last_online"] else None,
"city": user["city"],
"country_short": user["country_short"],
})
total = await self.services.user.count(deleted_at=None)
logger.debug(f"Found {len(users)} users, total={total}")
return {"users": users, "total": total, "page": page, "per_page": per_page}
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
for field in allowed_fields:
if field in updates:
if field == "is_admin" and target_uid == admin_uid:
raise ValueError("Cannot modify own admin status")
user[field] = updates[field]
await self.services.user.save(user)
logger.info(f"User {target_uid} updated by admin {admin_uid}")
return {"success": True}
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} banning user {target_uid}")
if admin_uid == target_uid:
raise ValueError("Cannot ban yourself")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = True
await self.services.user.save(user)
logger.info(f"User {target_uid} banned by admin {admin_uid}")
return {"success": True}
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = False
await self.services.user.save(user)
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
return {"success": True}
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
if len(new_password) != 6:
raise ValueError("Password must be exactly 6 characters")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["password"] = new_password
await self.services.user.save(user)
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
return {"success": True}
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
channels = []
async for channel in self.services.channel.find(**filters):
if search and search.lower() not in (channel["name"] or "").lower():
continue
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
channels.append({
"uid": channel["uid"],
"name": channel["name"],
"description": channel["description"] or "",
"tag": channel["tag"],
"is_private": channel["is_private"] or False,
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
"member_count": member_count,
"message_count": message_count,
})
total = await self.services.channel.count(deleted_at=None)
logger.debug(f"Found {len(channels)} channels, total={total}")
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
async def get_channel_members(self, channel_uid: str) -> list[dict]:
logger.info(f"Getting members for channel {channel_uid}")
members = []
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
user = await self.services.user.get(uid=member["user_uid"])
members.append({
"uid": member["uid"],
"user_uid": member["user_uid"],
"username": user["username"] if user else "Unknown",
"nick": user["nick"] if user else "Unknown",
"is_owner": member["is_owner"] or False,
"is_banned": member["is_banned"] or False,
"joined_at": str(member["created_at"]) if member["created_at"] else None,
})
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
return members
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
allowed_fields = ["name", "description", "is_private"]
for field in allowed_fields:
if field in updates:
channel[field] = updates[field]
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
if channel["name"] == "public":
raise ValueError("Cannot delete the public channel")
channel["deleted_at"] = datetime.now()
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
return {"success": True}
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
deleted_count = 0
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
deleted_count += 1
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
return {"success": True, "deleted_count": deleted_count}
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
if channel_uid:
filters["channel_uid"] = channel_uid
if user_uid:
filters["user_uid"] = user_uid
messages = []
async for message in self.services.channel_message.find(**filters):
if query and query.lower() not in (message["message"] or "").lower():
continue
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:200] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
total = await self.services.channel_message.count(deleted_at=None)
logger.debug(f"Found {len(messages)} messages")
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
message = await self.services.channel_message.get(uid=message_uid)
if not message:
raise ValueError("Message not found")
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing forums: page={page}, per_page={per_page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
forums = []
async for forum in self.services.forum.find(**filters):
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"] or "",
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
"thread_count": thread_count,
})
total = await self.services.forum.count(deleted_at=None)
logger.debug(f"Found {len(forums)} forums, total={total}")
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
allowed_fields = ["name", "description", "is_active"]
for field in allowed_fields:
if field in updates:
forum[field] = updates[field]
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
forum["deleted_at"] = datetime.now()
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
threads = []
async for thread in self.services.thread.find(**filters):
user = await self.services.user.get(uid=thread["created_by_uid"])
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
threads.append({
"uid": thread["uid"],
"title": thread["title"],
"is_pinned": thread["is_pinned"] or False,
"is_locked": thread["is_locked"] or False,
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": thread["created_by_uid"],
"post_count": post_count,
})
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
logger.debug(f"Found {len(threads)} threads, total={total}")
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_pinned"] = not (thread["is_pinned"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
return {"success": True, "is_pinned": thread["is_pinned"]}
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_locked"] = not (thread["is_locked"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
return {"success": True, "is_locked": thread["is_locked"]}
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["deleted_at"] = datetime.now()
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
posts = []
async for post in self.services.post.find(**filters):
user = await self.services.user.get(uid=post["created_by_uid"])
posts.append({
"uid": post["uid"],
"content": post["content"][:200] if post["content"] else "",
"created_at": str(post["created_at"]) if post["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": post["created_by_uid"],
})
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
logger.debug(f"Found {len(posts)} posts, total={total}")
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
post = await self.services.post.get(uid=post_uid)
if not post:
raise ValueError("Post not found")
post["deleted_at"] = datetime.now()
await self.services.post.save(post)
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
drives = []
async for drive in self.services.drive.find(**filters):
user = await self.services.user.get(uid=drive["user_uid"])
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
drives.append({
"uid": drive["uid"],
"name": drive["name"] or "Drive",
"user_uid": drive["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
"item_count": item_count,
})
total = await self.services.drive.count(deleted_at=None)
logger.debug(f"Found {len(drives)} drives, total={total}")
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing items for drive {drive_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
items = []
async for item in self.services.drive_item.find(**filters):
items.append({
"uid": item["uid"],
"name": item["name"] or "",
"path": item["path"] or "",
"size": item["size"] or 0,
"mime_type": item["mime_type"] or "",
"created_at": str(item["created_at"]) if item["created_at"] else None,
})
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
logger.debug(f"Found {len(items)} items, total={total}")
return {"items": items, "total": total, "page": page, "per_page": per_page}
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
item = await self.services.drive_item.get(uid=item_uid)
if not item:
raise ValueError("Drive item not found")
item["deleted_at"] = datetime.now()
await self.services.drive_item.save(item)
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
repositories = []
async for repo in self.services.repository.find(**filters):
user = await self.services.user.get(uid=repo["user_uid"])
repositories.append({
"uid": repo["uid"],
"name": repo["name"],
"user_uid": repo["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_private": repo["is_private"] or False,
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
})
total = await self.services.repository.count(deleted_at=None)
logger.debug(f"Found {len(repositories)} repositories, total={total}")
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
repo = await self.services.repository.get(uid=repo_uid)
if not repo:
raise ValueError("Repository not found")
repo["deleted_at"] = datetime.now()
await self.services.repository.save(repo)
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing notifications: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
notifications = []
async for notif in self.services.notification.find(**filters):
user = await self.services.user.get(uid=notif["user_uid"])
notifications.append({
"uid": notif["uid"],
"message": (notif["message"] or "")[:100],
"type": notif["type"] or "",
"user_uid": notif["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_read": notif["is_read"] or False,
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
})
total = await self.services.notification.count(deleted_at=None)
logger.debug(f"Found {len(notifications)} notifications, total={total}")
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} sending mass notification")
if not message or len(message) > 500:
raise ValueError("Message must be between 1 and 500 characters")
sent_count = 0
if user_uids:
target_users = user_uids
else:
target_users = []
async for user in self.services.user.find(deleted_at=None, is_banned=False):
target_users.append(user["uid"])
for user_uid in target_users:
await self.services.notification.create(
user_uid=user_uid,
message=message,
notification_type="admin",
)
sent_count += 1
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
return {"success": True, "sent_count": sent_count}
async def get_kv_entries(self) -> list[dict]:
logger.info("Fetching KV store entries")
entries = []
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
entries.append({"key": row["key"], "value": row["value"]})
logger.debug(f"Found {len(entries)} KV entries")
return entries
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
if not key or len(key) > 255:
raise ValueError("Key must be between 1 and 255 characters")
if len(value) > 10000:
raise ValueError("Value must be less than 10000 characters")
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
logger.info(f"KV entry set: key={key}")
return {"success": True}
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
self.app.db["kv"].delete(key=key)
logger.info(f"KV entry deleted: key={key}")
return {"success": True}
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing push registrations: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
registrations = []
async for reg in self.services.push.find(**filters):
user = await self.services.user.get(uid=reg["user_uid"])
registrations.append({
"uid": reg["uid"],
"user_uid": reg["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
})
total = await self.services.push.count(deleted_at=None)
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
reg = await self.services.push.get(uid=registration_uid)
if not reg:
raise ValueError("Push registration not found")
reg["deleted_at"] = datetime.now()
await self.services.push.save(reg)
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
return {"success": True}
async def run_maintenance(self, admin_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} running maintenance")
self.app.db.query("VACUUM")
self.app.db.query("ANALYZE")
logger.info("Maintenance completed: VACUUM and ANALYZE")
return {"success": True, "message": "Database maintenance completed"}
+1 -3
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import pathlib
from datetime import datetime
@@ -15,7 +13,7 @@ class ChannelService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
except:
pass
return folder
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import mimetypes
from snek.system.service import BaseService
+2 -5
View File
@@ -1,6 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.model import now
from snek.system.service import BaseService
@@ -11,12 +8,11 @@ class ChannelMemberService(BaseService):
async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member)
async def get_user_uids(self, channel_uid):
async for model in self.mapper.query(
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid AND deleted_at IS NULL AND is_banned = 0",
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
{"channel_uid": channel_uid},
):
yield model["user_uid"]
@@ -60,6 +56,7 @@ class ChannelMemberService(BaseService):
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
async def get_other_dm_user(self, channel_uid, user_uid):
+36 -67
View File
@@ -1,24 +1,6 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import pathlib
from concurrent.futures import ProcessPoolExecutor
from snek.system.service import BaseService
from snek.system.template import sanitize_html
logger = logging.getLogger(__name__)
jinja2_env = None
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template = jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
from snek.system.template import whitelist_attributes
import time
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
@@ -26,23 +8,33 @@ class ChannelMessageService(BaseService):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._configured_indexes = False
self._executor_pools = {}
global jinja2_env
jinja2_env = self.app.jinja2_env
self._max_workers = 1
def get_or_create_executor(self, uid):
if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=self._max_workers)
logger.debug(f"Executors available: {len(self._executor_pools)}")
return self._executor_pools[uid]
def delete_executor(self, uid):
if uid in self._executor_pools:
self._executor_pools[uid].shutdown()
del self._executor_pools[uid]
async def maintenance(self):
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):
@@ -57,7 +49,6 @@ class ChannelMessageService(BaseService):
break
async def create(self, channel_uid, user_uid, message, is_final=True):
logger.info(f"create: channel_uid={channel_uid}, user_uid={user_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
model = await self.new()
model["channel_uid"] = channel_uid
@@ -70,9 +61,6 @@ class ChannelMessageService(BaseService):
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.error(f"create: user not found user_uid={user_uid}")
raise Exception("User not found")
context.update(
{
"user_uid": user["uid"],
@@ -81,15 +69,13 @@ class ChannelMessageService(BaseService):
"color": user["color"],
}
)
loop = asyncio.get_event_loop()
context['message'] = whitelist_attributes(context['message'])
try:
context = json.loads(json.dumps(context, default=str))
logger.debug(f"create: rendering html for message uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
except Exception as ex:
logger.error(f"create: html rendering failed: {ex}")
print(ex, flush=True)
logger.debug(f"create: saving message uid={model['uid']}")
if await super().save(model):
if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index(
@@ -105,11 +91,7 @@ class ChannelMessageService(BaseService):
["deleted_at"], unique=False
)
self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
logger.info(f"create: message created successfully uid={model['uid']}, channel={channel_uid}")
return model
logger.error(f"create: failed to save message channel={channel_uid}, errors={model.errors}")
raise Exception(f"Failed to create channel message: {model.errors}.")
async def to_extended_dict(self, message):
@@ -134,13 +116,10 @@ class ChannelMessageService(BaseService):
}
async def save(self, model):
logger.debug(f"save: starting for uid={model['uid']}, is_final={model['is_final']}")
context = {}
context.update(model.record)
context['message'] = whitelist_attributes(context['message'])
user = await self.app.services.user.get(model["user_uid"])
if not user:
logger.error(f"save: user not found user_uid={model['user_uid']}")
return False
context.update(
{
"user_uid": user["uid"],
@@ -149,19 +128,9 @@ class ChannelMessageService(BaseService):
"color": user["color"],
}
)
context = json.loads(json.dumps(context, default=str))
loop = asyncio.get_event_loop()
logger.debug(f"save: rendering html for uid={model['uid']}")
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context)
result = await super().save(model)
if result:
logger.debug(f"save: message saved successfully uid={model['uid']}")
else:
logger.warning(f"save: failed to save message uid={model['uid']}")
if model['is_final']:
self.delete_executor(model['uid'])
return result
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)
@@ -207,6 +176,6 @@ class ChannelMessageService(BaseService):
results.append(model)
except Exception as ex:
logger.error(f"offset query failed: {ex}")
print(ex)
results.sort(key=lambda x: x["created_at"])
return results
-38
View File
@@ -1,29 +1,17 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class ChatService(BaseService):
async def finalize(self, message_uid):
logger.info(f"finalize: starting for message_uid={message_uid}")
channel_message = await self.services.channel_message.get(uid=message_uid)
if not channel_message:
logger.warning(f"finalize: message not found uid={message_uid}")
return
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
logger.debug(f"finalize: message marked as final uid={message_uid}")
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"finalize: broadcasting message to channel={channel['uid']}")
await self.services.socket.broadcast(
channel["uid"],
{
@@ -40,31 +28,18 @@ class ChatService(BaseService):
"is_final": channel_message["is_final"],
},
)
logger.info(f"finalize: completed for message_uid={message_uid}, channel={channel['uid']}")
await self.app.create_task(
self.services.notification.create_channel_message(message_uid)
)
await self.app.create_task(
self.services.mention.create_mentions_for_message(
message_uid=channel_message["uid"],
channel_uid=channel_message["channel_uid"],
message_text=channel_message["message"],
author_user_uid=channel_message["user_uid"]
)
)
async def send(self, user_uid, channel_uid, message, is_final=True):
logger.info(f"send: user_uid={user_uid}, channel_uid={channel_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
logger.error(f"send: channel not found channel_uid={channel_uid}")
raise Exception("Channel not found.")
logger.debug(f"send: checking for existing non-final message in channel={channel_uid}")
channel_message = await self.services.channel_message.get(
channel_uid=channel_uid,user_uid=user_uid, is_final=False
)
if channel_message:
logger.debug(f"send: updating existing message uid={channel_message['uid']}")
channel_message["message"] = message
channel_message["is_final"] = is_final
if not channel_message["is_final"]:
@@ -73,18 +48,15 @@ class ChatService(BaseService):
else:
await self.services.channel_message.save(channel_message)
else:
logger.debug(f"send: creating new message in channel={channel_uid}")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"]
logger.debug(f"send: message saved uid={channel_message_uid}")
user = await self.services.user.get(uid=user_uid)
channel["last_message_on"] = now()
await self.services.channel.save(channel)
logger.debug(f"send: broadcasting message to channel={channel_uid}")
await self.services.socket.broadcast(
channel_uid,
{
@@ -101,18 +73,8 @@ class ChatService(BaseService):
"is_final": is_final,
},
)
logger.info(f"send: completed message_uid={channel_message_uid}, channel={channel_uid}, is_final={is_final}")
await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid)
)
if is_final:
await self.app.create_task(
self.services.mention.create_mentions_for_message(
message_uid=channel_message["uid"],
channel_uid=channel_uid,
message_text=channel_message["message"],
author_user_uid=user_uid
)
)
return channel_message
+1 -2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.docker import ComposeFileManager
from snek.system.service import BaseService
@@ -118,3 +116,4 @@ class ContainerService(BaseService):
if await super().save(model):
return model
raise Exception(f"Failed to create container: {model.errors}")
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import dataset
from snek.system.service import BaseService
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.system.service import BaseService
+6 -12
View File
@@ -1,13 +1,9 @@
# retoor <retoor@molodetz.nl>
# services/forum.py
from snek.system.service import BaseService
import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
import asyncio
import inspect
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
@@ -46,12 +42,10 @@ class BaseForumService(BaseService):
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if inspect.iscoroutinefunction(listener):
if hasattr(listener, "__await__"): # async function or coro
await listener(event_name, data)
else:
result = listener(event_name, data)
if inspect.isawaitable(result):
await result
else: # plain sync function
listener(event_name, data)
async def notify(self, event_name: str, data: Any) -> None:
"""
@@ -165,7 +159,7 @@ class ThreadService(BaseForumService):
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user["is_admin"]:
if not user.get("is_admin"):
return None
thread["is_pinned"] = not thread["is_pinned"]
@@ -233,7 +227,7 @@ class PostService(BaseForumService):
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user["is_admin"]:
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return None
post["content"] = content
@@ -252,7 +246,7 @@ class PostService(BaseForumService):
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user["is_admin"]:
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return False
# Don't allow deleting first post
-139
View File
@@ -1,139 +0,0 @@
# retoor <retoor@molodetz.nl>
import re
import logging
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class MentionService(BaseService):
mapper_name = "mention"
MENTION_REGEX = re.compile(r'@([a-zA-Z0-9_-]+)')
def extract_mentions(self, text):
if not text:
return []
return self.MENTION_REGEX.findall(text)
async def resolve_mention_to_user(self, mention_text):
if not mention_text:
return None
user = await self.services.user.get(username=mention_text)
if user:
return user
async for u in self.services.user.find(nick=mention_text, _limit=1):
return u
return None
async def create_mentions_for_message(self, message_uid, channel_uid, message_text, author_user_uid):
mentions = self.extract_mentions(message_text)
if not mentions:
return []
created_mentions = []
seen_user_uids = set()
for mention_text in mentions:
user = await self.resolve_mention_to_user(mention_text)
if not user:
continue
if user["uid"] == author_user_uid:
continue
if user["uid"] in seen_user_uids:
continue
seen_user_uids.add(user["uid"])
model = await self.new()
model["message_uid"] = message_uid
model["channel_uid"] = channel_uid
model["mentioned_user_uid"] = user["uid"]
model["author_user_uid"] = author_user_uid
try:
if await self.save(model):
created_mentions.append(model)
await self.services.socket.send_to_user(
user["uid"],
{
"event": "new_mention",
"data": {
"mention_uid": model["uid"],
"message_uid": message_uid,
"channel_uid": channel_uid,
"author_user_uid": author_user_uid
}
}
)
except Exception as ex:
logger.warning(f"Failed to create mention: {ex}")
return created_mentions
async def get_unread_mentions(self, user_uid, channel_uid=None):
kwargs = {
"mentioned_user_uid": user_uid,
"read_at": None,
"deleted_at": None
}
if channel_uid:
kwargs["channel_uid"] = channel_uid
results = []
async for mention in self.find(**kwargs):
results.append(mention)
return results
async def get_unread_count(self, user_uid, channel_uid=None):
kwargs = {
"mentioned_user_uid": user_uid,
"read_at": None,
"deleted_at": None
}
if channel_uid:
kwargs["channel_uid"] = channel_uid
return await self.count(**kwargs)
async def mark_read(self, mention_uid, user_uid):
mention = await self.get(uid=mention_uid)
if not mention:
return False
if mention["mentioned_user_uid"] != user_uid:
return False
mention["read_at"] = now()
return await self.save(mention)
async def mark_all_read_in_channel(self, user_uid, channel_uid):
count = 0
async for mention in self.find(
mentioned_user_uid=user_uid,
channel_uid=channel_uid,
read_at=None,
deleted_at=None
):
mention["read_at"] = now()
try:
if await self.save(mention):
count += 1
except Exception as ex:
logger.warning(f"Failed to mark mention as read: {ex}")
return count
async def get_oldest_unread_mention(self, user_uid, channel_uid):
sql = """
SELECT m.* FROM mention m
JOIN channel_message cm ON m.message_uid = cm.uid
WHERE m.mentioned_user_uid = :user_uid
AND m.channel_uid = :channel_uid
AND m.read_at IS NULL
AND m.deleted_at IS NULL
AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT 1
"""
async for record in self.query(sql, {"user_uid": user_uid, "channel_uid": channel_uid}):
return record
return None
+1 -7
View File
@@ -1,13 +1,7 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.markdown import strip_markdown
from snek.system.model import now
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class NotificationService(BaseService):
mapper_name = "notification"
@@ -85,6 +79,6 @@ class NotificationService(BaseService):
},
)
except Exception as e:
logger.warning(f"Failed to send push notification: {e}")
print(f"Failed to send push notification:", e)
self.app.db.commit()
-87
View File
@@ -1,87 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from typing import Optional, List
from snek.system.service import BaseService
from snek.system.exceptions import ValidationError, DuplicateResourceError
logger = logging.getLogger(__name__)
class ProfilePageService(BaseService):
mapper_name = "profile_page"
def slugify(self, title: str) -> str:
slug = title.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug[:100]
async def create_page(self, user_uid: str, title: str, content: str = "", is_published: bool = True) -> dict:
slug = self.slugify(title)
existing = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if existing:
raise DuplicateResourceError(f"A page with slug '{slug}' already exists")
pages = [p async for p in self.find(user_uid=user_uid, deleted_at=None)]
max_order = max([p["order_index"] for p in pages], default=-1)
model = await self.new()
model["user_uid"] = user_uid
model["title"] = title
model["slug"] = slug
model["content"] = content
model["order_index"] = max_order + 1
model["is_published"] = is_published
await self.save(model)
return model
async def update_page(self, page_uid: str, title: Optional[str] = None,
content: Optional[str] = None, is_published: Optional[bool] = None) -> dict:
page = await self.get(uid=page_uid, deleted_at=None)
if not page:
raise ValidationError("Page not found")
if title is not None:
page["title"] = title
new_slug = self.slugify(title)
existing = await self.get(user_uid=page["user_uid"], slug=new_slug, deleted_at=None)
if existing and existing["uid"] != page_uid:
raise DuplicateResourceError(f"A page with slug '{new_slug}' already exists")
page["slug"] = new_slug
if content is not None:
page["content"] = content
if is_published is not None:
page["is_published"] = is_published
return await self.save(page)
async def get_user_pages(self, user_uid: str, include_unpublished: bool = False) -> List[dict]:
if include_unpublished:
pages = [p.record async for p in self.find(user_uid=user_uid, deleted_at=None)]
else:
pages = [p.record async for p in self.find(user_uid=user_uid, is_published=True, deleted_at=None)]
return sorted(pages, key=lambda p: p["order_index"])
async def get_page_by_slug(self, user_uid: str, slug: str, include_unpublished: bool = False) -> Optional[dict]:
page = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
if page and (include_unpublished or page["is_published"]):
return page
return None
async def reorder_pages(self, user_uid: str, page_uids: List[str]) -> None:
for index, page_uid in enumerate(page_uids):
page = await self.get(uid=page_uid, user_uid=user_uid, deleted_at=None)
if page:
page["order_index"] = index
await self.save(page)
async def delete_page(self, page_uid: str) -> None:
page = await self.get(uid=page_uid, deleted_at=None)
if page:
await self.delete(page)
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import base64
import json
import os.path
+2 -14
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import asyncio
import shutil
@@ -13,7 +11,7 @@ class RepositoryService(BaseService):
loop = asyncio.get_event_loop()
repository_path = (
await self.services.user.get_repository_path(user_uid)
).joinpath(name + ".git")
).joinpath(name)
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
@@ -41,7 +39,7 @@ class RepositoryService(BaseService):
stdout, stderr = await process.communicate()
return process.returncode == 0
async def create(self, user_uid, name, is_private=False, description=None):
async def create(self, user_uid, name, is_private=False):
if await self.exists(user_uid=user_uid, name=name):
return False
@@ -52,14 +50,4 @@ class RepositoryService(BaseService):
model["user_uid"] = user_uid
model["name"] = name
model["is_private"] = is_private
model["description"] = description or ""
return await self.save(model)
async def list_by_user(self, user_uid):
repositories = []
async for repo in self.find(user_uid=user_uid):
repositories.append(repo)
return repositories
async def get_by_name(self, user_uid, name):
return await self.get(user_uid=user_uid, name=name)
+47 -317
View File
@@ -1,36 +1,12 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from datetime import datetime
from snek.model.user import UserModel
from snek.system.service import BaseService
from snek.system.model import now
logger = logging.getLogger(__name__)
def safe_get(obj, key, default=None):
if obj is None:
return default
try:
if isinstance(obj, dict):
return obj.get(key, default)
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
val = obj[key]
return val if val is not None else default
return getattr(obj, key, default)
except (KeyError, TypeError, AttributeError):
return default
def safe_str(obj):
if obj is None:
return ""
try:
return str(obj)
except Exception:
return ""
from snek.system.model import now
class SocketService(BaseService):
@@ -40,41 +16,23 @@ class SocketService(BaseService):
self.ws = ws
self.is_connected = True
self.user = user
self.user_uid = safe_get(user, "uid") if user else None
self.user_color = safe_get(user, "color") if user else None
self.subscribed_channels = set()
async def send_json(self, data):
if data is None:
return False
if not self.is_connected:
return False
if not self.ws:
self.is_connected = False
return False
try:
await self.ws.send_json(data)
return True
except ConnectionResetError:
except Exception:
self.is_connected = False
logger.debug("Connection reset during send_json")
except Exception as ex:
self.is_connected = False
logger.debug(f"send_json failed: {safe_str(ex)}")
return False
return self.is_connected
async def close(self):
if not self.is_connected:
return True
await self.ws.close()
self.is_connected = False
self.subscribed_channels.clear()
try:
if self.ws and not self.ws.closed:
await asyncio.wait_for(self.ws.close(), timeout=300.0)
except asyncio.TimeoutError:
logger.debug("Socket close timed out")
except Exception as ex:
logger.debug(f"Socket close failed: {safe_str(ex)}")
return True
def __init__(self, app):
@@ -83,292 +41,64 @@ class SocketService(BaseService):
self.users = {}
self.subscriptions = {}
self.last_update = str(datetime.now())
self._lock = asyncio.Lock()
async def user_availability_service(self):
logger.info("User availability update service started.")
while True:
try:
sockets_copy = list(self.sockets)
users_to_update = []
seen_user_uids = set()
for s in sockets_copy:
try:
if not s or not s.is_connected:
continue
if not s.user or not s.user_uid:
continue
if s.user_uid in seen_user_uids:
continue
seen_user_uids.add(s.user_uid)
users_to_update.append(s.user_uid)
except Exception as ex:
logger.debug(f"Failed to check user availability: {safe_str(ex)}")
for user_uid in users_to_update:
try:
if self.app and hasattr(self.app, "services") and self.app.services:
user = await self.app.services.user.get(uid=user_uid)
if user:
user["last_ping"] = now()
await self.app.services.user.save(user)
except Exception as ex:
logger.debug(f"Failed to update user availability: {safe_str(ex)}")
logger.info(f"Updated user availability for {len(users_to_update)} online users.")
except Exception as ex:
logger.warning(f"User availability service error: {safe_str(ex)}")
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("User availability service cancelled")
break
logger.info("Updating user availability...")
users_updated = []
for s in self.sockets:
if not s.is_connected:
continue
if s.user not in users_updated:
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
users_updated.append(s.user)
logger.info(
f"Updated user availability for {len(users_updated)} online users."
)
await asyncio.sleep(60)
async def add(self, ws, user_uid):
if not ws:
return None
if not user_uid:
return None
try:
if not self.app or not hasattr(self.app, "services") or not self.app.services:
logger.warning("Services not available for socket add")
return None
user = await self.app.services.user.get(uid=user_uid)
if not user:
logger.warning(f"User not found for socket add: {user_uid}")
return None
s = self.Socket(ws, user)
username = safe_get(user, "username", "unknown")
nick = safe_get(user, "nick") or username
color = s.user_color
async with self._lock:
self.sockets.add(s)
is_first_connection = False
if user_uid not in self.users:
self.users[user_uid] = set()
is_first_connection = True
elif len(self.users[user_uid]) == 0:
is_first_connection = True
self.users[user_uid].add(s)
try:
fresh_user = await self.app.services.user.get(uid=user_uid)
if fresh_user:
fresh_user["last_ping"] = now()
await self.app.services.user.save(fresh_user)
except Exception as ex:
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
logger.info(f"Added socket for user {username}")
if is_first_connection:
await self._broadcast_presence("arrived", user_uid, nick, color)
return s
except Exception as ex:
logger.warning(f"Failed to add socket: {safe_str(ex)}")
return None
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
logger.info(f"Added socket for user {s.user['username']}")
if not self.users.get(user_uid):
self.users[user_uid] = set()
self.users[user_uid].add(s)
async def subscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
existing_socket = None
user_sockets = self.users.get(user_uid, set())
for sock in user_sockets:
if sock and sock.ws == ws:
existing_socket = sock
break
if not existing_socket:
return False
existing_socket.subscribed_channels.add(channel_uid)
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(user_uid)
return True
except Exception as ex:
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
return False
async def unsubscribe(self, ws, channel_uid, user_uid):
if not ws or not channel_uid or not user_uid:
return False
try:
async with self._lock:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
for s in self.sockets:
if s and s.ws == ws:
s.subscribed_channels.discard(channel_uid)
break
return True
except Exception as ex:
logger.warning(f"Failed to unsubscribe: {safe_str(ex)}")
return False
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.subscriptions[channel_uid].add(s)
async def send_to_user(self, user_uid, message):
if not user_uid or message is None:
return 0
count = 0
try:
user_sockets = list(self.users.get(user_uid, []))
for s in user_sockets:
if not s:
continue
try:
if await s.send_json(message):
count += 1
except Exception as ex:
logger.debug(f"Failed to send to user socket: {safe_str(ex)}")
except Exception as ex:
logger.warning(f"send_to_user failed: {safe_str(ex)}")
for s in self.users.get(user_uid, []):
if await s.send_json(message):
count += 1
return count
async def broadcast(self, channel_uid, message):
if not channel_uid or message is None:
logger.debug(f"broadcast: invalid params channel_uid={channel_uid}, message={message is not None}")
return False
logger.debug(f"broadcast: starting for channel={channel_uid}")
return await self._broadcast(channel_uid, message)
await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
if not channel_uid or message is None:
return False
sent = 0
user_uids_to_send = set()
try:
if self.services:
try:
async for user_uid in self.services.channel_member.get_user_uids(channel_uid):
if user_uid:
user_uids_to_send.add(user_uid)
logger.debug(f"_broadcast: found {len(user_uids_to_send)} users from db for channel={channel_uid}")
except Exception as ex:
logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
if not user_uids_to_send:
if channel_uid in self.subscriptions:
user_uids_to_send = set(self.subscriptions[channel_uid])
logger.debug(f"_broadcast: using {len(user_uids_to_send)} users from subscriptions for channel={channel_uid}")
send_tasks = []
for user_uid in user_uids_to_send:
send_tasks.append(self._send_to_user_safe(user_uid, message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
for result in results:
if isinstance(result, int):
sent += result
logger.info(f"_broadcast: completed channel={channel_uid}, total_users={len(user_uids_to_send)}, sent={sent}")
return True
async for user_uid in self.services.channel_member.get_user_uids(
channel_uid
):
sent += await self.send_to_user(user_uid, message)
except Exception as ex:
logger.warning(f"Broadcast failed: {safe_str(ex)}")
return False
async def _send_to_user_safe(self, user_uid, message):
try:
return await self.send_to_user(user_uid, message)
except Exception as ex:
logger.debug(f"Failed to send to user {user_uid}: {safe_str(ex)}")
return 0
print(ex, flush=True)
logger.info(f"Broadcasted a message to {sent} users.")
return True
async def delete(self, ws):
if not ws:
return
async with self._lock:
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
for s in sockets_to_remove:
self.sockets.discard(s)
departures_to_broadcast = []
channels_to_cleanup = set()
for s in sockets_to_remove:
user_uid = s.user_uid
if not user_uid:
continue
is_last_connection = False
if user_uid in self.users:
self.users[user_uid].discard(s)
if len(self.users[user_uid]) == 0:
del self.users[user_uid]
is_last_connection = True
if is_last_connection:
user_nick = None
try:
if s.user:
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
except Exception:
pass
if user_nick:
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
for channel_uid in list(s.subscribed_channels):
channels_to_cleanup.add((channel_uid, user_uid))
for channel_uid, user_uid in channels_to_cleanup:
try:
if channel_uid in self.subscriptions:
self.subscriptions[channel_uid].discard(user_uid)
if len(self.subscriptions[channel_uid]) == 0:
del self.subscriptions[channel_uid]
except Exception as ex:
logger.debug(f"Failed to cleanup channel subscription: {safe_str(ex)}")
for s in sockets_to_remove:
try:
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
logger.info(f"Removed socket for user {username}")
await s.close()
except Exception as ex:
logger.warning(f"Socket close failed: {safe_str(ex)}")
for user_uid, user_nick, user_color in departures_to_broadcast:
try:
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
except Exception as ex:
logger.debug(f"Failed to broadcast departure: {safe_str(ex)}")
async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color):
if not user_uid or not user_nick:
return
if not event_type or event_type not in ("arrived", "departed"):
return
try:
message = {
"event": "user_presence",
"data": {
"type": event_type,
"user_uid": user_uid,
"user_nick": user_nick,
"user_color": user_color,
"timestamp": datetime.now().isoformat(),
},
}
sockets_copy = list(self.sockets)
send_tasks = []
for s in sockets_copy:
if not s or not s.is_connected:
continue
if s.user_uid == user_uid:
continue
send_tasks.append(s.send_json(message))
if send_tasks:
results = await asyncio.gather(*send_tasks, return_exceptions=True)
sent_count = sum(1 for r in results if r is True)
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
except Exception as ex:
logger.warning(f"Broadcast presence failed: {safe_str(ex)}")
async def get_connected_users(self):
try:
return list(self.users.keys())
except Exception:
return []
async def get_user_socket_count(self, user_uid):
if not user_uid:
return 0
try:
return len(self.users.get(user_uid, []))
except Exception:
return 0
async def is_user_online(self, user_uid):
if not user_uid:
return False
try:
user_sockets = self.users.get(user_uid, set())
return any(s.is_connected for s in user_sockets if s)
except Exception:
return False
for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close()
logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s)
+2 -6
View File
@@ -1,9 +1,5 @@
# retoor <retoor@molodetz.nl>
import sqlite3
from snek.system.service import BaseService
from snek.system.service import BaseService
import sqlite3
class StatisticsService(BaseService):
+2 -6
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import pathlib
from snek.system import security
@@ -80,7 +78,7 @@ class UserService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
except:
pass
return folder
@@ -89,7 +87,7 @@ class UserService(BaseService):
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
except:
pass
return folder
@@ -102,8 +100,6 @@ class UserService(BaseService):
model.email.value = email
model.username.value = username
model.password.value = await security.hash(password)
if not await self.count():
model["is_admin"] = True
if await self.save(model):
if model:
channel = await self.services.channel.ensure_public_channel(
-3
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import json
from snek.system.service import BaseService
@@ -17,7 +15,6 @@ class UserPropertyService(BaseService):
},
["user_uid", "name"],
)
self.mapper.db.commit()
async def get(self, user_uid, name):
try:
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import random
from snek.system.service import BaseService
+57 -178
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import asyncio
import base64
import json
@@ -18,72 +16,61 @@ logger = logging.getLogger("git_server")
class GitApplication(web.Application):
def __init__(self, parent=None):
import git
globals()['git'] = git
# import git
# globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes(
[
web.post("/{username}/{repo_name}/create", self.create_repository),
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
web.get("/{username}/{repo_name}/clone", self.clone_repository),
web.post("/{username}/{repo_name}/push", self.push_repository),
web.post("/{username}/{repo_name}/pull", self.pull_repository),
web.get("/{username}/{repo_name}/status", self.status_repository),
web.get("/{username}/{repo_name}/branches", self.list_branches),
web.post("/{username}/{repo_name}/branches", self.create_branch),
web.get("/{username}/{repo_name}/log", self.commit_log),
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
web.post("/create/{repo_name}", self.create_repository),
web.delete("/delete/{repo_name}", self.delete_repository),
web.get("/clone/{repo_name}", self.clone_repository),
web.post("/push/{repo_name}", self.push_repository),
web.post("/pull/{repo_name}", self.pull_repository),
web.get("/status/{repo_name}", self.status_repository),
# web.get('/list', self.list_repositories),
web.get("/branches/{repo_name}", self.list_branches),
web.post("/branches/{repo_name}", self.create_branch),
web.get("/log/{repo_name}", self.commit_log),
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
web.get("/{path:.+}/info/refs", self.git_smart_http),
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
web.get("/{repo_name}.git/info/refs", self.git_smart_http),
web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None, None, None
return None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
request["auth_user"] = await self.parent.services.user.authenticate(
request["user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
if not request["auth_user"]:
return None, None, None
path_username = request.match_info.get("username")
if not path_username:
return None, None, None
if path_username.count("-") == 4:
target_user = await self.parent.services.user.get(uid=path_username)
else:
target_user = await self.parent.services.user.get(username=path_username)
if not target_user:
return None, None, None
request["target_user"] = target_user
if not request["user"]:
return None, None
request["repository_path"] = (
await self.parent.services.user.get_repository_path(target_user["uid"])
await self.parent.services.user.get_repository_path(request["user"]["uid"])
)
return request["auth_user"]["username"], target_user, request["repository_path"]
return request["user"]["username"], request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, target_user, repository_path = await self.check_basic_auth(request)
if not username or not target_user or not repository_path:
username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path:
return web.Response(
status=401,
headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
request["target_user"] = target_user
request["repository_path"] = repository_path
return await handler(self, request, *args, **kwargs)
@@ -100,17 +87,9 @@ class GitApplication(web.Application):
@require_auth
async def create_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only create repositories in your own namespace",
status=403,
)
if not repo_name or "/" in repo_name or ".." in repo_name:
return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name)
@@ -118,7 +97,7 @@ class GitApplication(web.Application):
return web.Response(text="Repository already exists", status=400)
try:
git.Repo.init(repo_dir, bare=True)
logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
logger.info(f"Created repository: {repo_name} for user {username}")
return web.Response(text=f"Created repository {repo_name}")
except Exception as e:
logger.error(f"Error creating repository {repo_name}: {str(e)}")
@@ -126,22 +105,16 @@ class GitApplication(web.Application):
@require_auth
async def delete_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only delete your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
#'''
try:
shutil.rmtree(self.repo_path(repository_path, repo_name))
logger.info(f"Deleted repository: {repo_name} for user {auth_user['username']}")
logger.info(f"Deleted repository: {repo_name} for user {username}")
return web.Response(text=f"Deleted repository {repo_name}")
except Exception as e:
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
@@ -149,20 +122,9 @@ class GitApplication(web.Application):
@require_auth
async def clone_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -177,16 +139,9 @@ class GitApplication(web.Application):
@require_auth
async def push_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only push to your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -220,21 +175,14 @@ class GitApplication(web.Application):
temp_repo.index.commit(commit_message)
origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
logger.info(f"Pushed to repository: {repo_name} for user {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only pull to your own repositories", status=403
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -262,7 +210,7 @@ class GitApplication(web.Application):
origin = local_repo.remote("origin")
origin.push()
logger.info(
f"Pulled to repository {repo_name} from {remote_url} for user {auth_user['username']}"
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}"
@@ -273,20 +221,9 @@ class GitApplication(web.Application):
@require_auth
async def status_repository(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -354,20 +291,9 @@ class GitApplication(web.Application):
@require_auth
async def list_branches(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -380,17 +306,9 @@ class GitApplication(web.Application):
@require_auth
async def create_branch(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
username = request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
if auth_user["uid"] != target_user["uid"]:
return web.Response(
text="Forbidden: can only create branches in your own repositories",
status=403,
)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -410,7 +328,7 @@ class GitApplication(web.Application):
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push("origin", branch_name)
logger.info(
f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
f"Created branch {branch_name} in repository {repo_name} for user {username}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
@@ -421,20 +339,9 @@ class GitApplication(web.Application):
@require_auth
async def commit_log(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -476,22 +383,11 @@ class GitApplication(web.Application):
@require_auth
async def file_content(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repo_name = request.match_info["repo_name"]
file_path = request.match_info.get("file_path", "")
branch = request.query.get("branch", "main")
repository_path = request["repository_path"]
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
return web.Response(text="Repository not found", status=404)
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -537,42 +433,25 @@ class GitApplication(web.Application):
@require_auth
async def git_smart_http(self, request):
auth_user = request["auth_user"]
target_user = request["target_user"]
request["username"]
repository_path = request["repository_path"]
repo_name = request.match_info.get("repo_name")
path_username = request.match_info.get("username")
path = request.path
repo = await self.parent.services.repository.get(
user_uid=target_user["uid"], name=repo_name
)
if not repo:
return web.Response(text="Repository not found", status=404)
is_owner = auth_user["uid"] == target_user["uid"]
is_write_operation = "/git-receive-pack" in path
if is_write_operation and not is_owner:
logger.warning(
f"Push denied: {auth_user['username']} attempted to push to {path_username}/{repo_name}"
)
return web.Response(
text="Push denied: only repository owner can push", status=403
)
if not is_owner and repo["is_private"]:
logger.warning(
f"Access denied: {auth_user['username']} attempted to access private repo {path_username}/{repo_name}"
)
return web.Response(text="Repository not found", status=404)
async def get_repository_path():
req_path = path.lstrip("/")
if req_path.endswith("/info/refs"):
repo_name = req_path[: -len("/info/refs")]
elif req_path.endswith("/git-upload-pack"):
repo_name = req_path[: -len("/git-upload-pack")]
elif req_path.endswith("/git-receive-pack"):
repo_name = req_path[: -len("/git-receive-pack")]
else:
repo_name = req_path
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(
f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
)
logger.info(f"Resolved repo path: {repo_dir}")
return repo_dir
async def handle_info_refs(service):
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
from snek.app import Application
from IPython import start_ipython
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import aiohttp
ENABLED = False
-2
View File
@@ -1,5 +1,3 @@
# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path
-696
View File
@@ -1,696 +0,0 @@
/* retoor <retoor@molodetz.nl> */
html:has(body.admin-page) {
height: auto;
min-height: 100%;
}
body.admin-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.admin-page main {
overflow: visible;
flex: none;
}
:root {
--admin-bg: #000000;
--admin-bg-secondary: #0f0f0f;
--admin-bg-tertiary: #1a1a1a;
--admin-accent: #f05a28;
--admin-accent-hover: #e04924;
--admin-text: #e6e6e6;
--admin-text-muted: #888;
--admin-text-faded: #aaa;
--admin-border: #333;
--admin-border-light: #444;
--admin-danger: #8b0000;
--admin-danger-hover: #a00000;
--admin-warning: #c9a227;
--admin-success: #2e7d32;
}
.admin-content {
padding: 20px;
max-width: 1400px;
}
.admin-sidebar h2 {
color: var(--admin-accent);
font-size: 0.9em;
margin-bottom: 15px;
}
.admin-sidebar hr {
border: none;
border-top: 1px solid var(--admin-border);
margin: 20px 0;
}
.admin-stats {
margin-bottom: 30px;
}
.admin-stats h2 {
margin-bottom: 20px;
color: var(--admin-accent);
font-size: 1.3em;
}
.admin-stats h3 {
margin-top: 25px;
margin-bottom: 15px;
color: var(--admin-text-muted);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.time-cell {
font-size: 0.85em;
color: var(--admin-text-muted);
white-space: nowrap;
}
.stat-card {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 20px;
text-align: center;
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100px;
}
.stat-card:hover {
border-color: var(--admin-accent);
}
.stat-card.stat-highlight {
border-color: var(--admin-accent);
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
}
.stat-label {
display: block;
font-size: 0.75em;
color: var(--admin-text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-value {
display: block;
font-size: 2.5em;
font-weight: bold;
color: var(--admin-accent);
line-height: 1;
}
.stat-sub {
display: block;
font-size: 0.8em;
color: var(--admin-success);
margin-top: 8px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.info-item {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 15px;
}
.info-label {
display: block;
font-size: 0.8em;
color: var(--admin-text-muted);
text-transform: uppercase;
margin-bottom: 5px;
}
.info-value {
display: block;
font-size: 1.1em;
color: var(--admin-text);
}
.admin-activity {
margin-bottom: 30px;
}
.admin-activity h2 {
margin-bottom: 15px;
color: var(--admin-accent);
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--admin-bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.admin-table thead {
background: var(--admin-bg-tertiary);
}
.admin-table th {
text-align: left;
padding: 12px 15px;
color: var(--admin-text-muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--admin-border);
}
.admin-table td {
padding: 12px 15px;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text);
}
.admin-table tbody tr:hover {
background: var(--admin-bg-tertiary);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.message-preview,
.description-preview,
.value-preview,
.path-preview,
.endpoint-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-banned {
background: rgba(139, 0, 0, 0.1);
}
.row-inactive {
opacity: 0.6;
}
.row-pinned {
background: rgba(240, 90, 40, 0.1);
}
.row-locked {
opacity: 0.7;
}
.row-read {
opacity: 0.6;
}
.actions {
white-space: nowrap;
}
.btn {
display: inline-block;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--admin-border);
background: var(--admin-bg-tertiary);
color: var(--admin-text);
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.btn:hover {
background: #2a2a2a;
border-color: var(--admin-border-light);
color: #fff;
}
.btn-primary {
background: var(--admin-accent);
border-color: var(--admin-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--admin-accent-hover);
border-color: var(--admin-accent-hover);
}
.btn-secondary {
background: var(--admin-bg-tertiary);
border-color: var(--admin-border);
}
.btn-danger {
background: var(--admin-danger);
border-color: var(--admin-danger);
color: #fff;
}
.btn-danger:hover {
background: var(--admin-danger-hover);
border-color: var(--admin-danger-hover);
}
.btn-warning {
background: var(--admin-warning);
border-color: var(--admin-warning);
color: #000;
}
.btn-warning:hover {
background: #d4ab2a;
border-color: #d4ab2a;
}
.btn-small {
padding: 4px 10px;
font-size: 12px;
}
.admin-search {
margin-bottom: 20px;
}
.admin-search form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.search-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
width: 100%;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: var(--admin-accent);
}
.search-select {
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-width: 150px;
}
.search-select:focus {
outline: none;
border-color: var(--admin-accent);
}
.admin-pagination {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
padding: 15px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
}
.pagination-info {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 15px;
}
.pagination-pages {
color: var(--admin-text);
font-weight: 500;
}
.admin-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--admin-border);
}
.admin-header h2 {
color: var(--admin-accent);
margin-bottom: 5px;
}
.admin-header p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-form {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.admin-form h2 {
color: var(--admin-accent);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: var(--admin-text-muted);
font-size: 0.9em;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type="color"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--admin-accent);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--admin-text-muted);
font-size: 0.85em;
}
.form-group.checkbox label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.form-group.checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--admin-accent);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-end;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-actions-inline {
flex: 0 0 auto;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 25px;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.radio-group input[type="radio"] {
accent-color: var(--admin-accent);
}
.user-checkboxes {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 10px;
background: var(--admin-bg);
}
.checkbox-label {
display: block;
padding: 5px 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 10px;
accent-color: var(--admin-accent);
}
.danger-zone {
border-color: var(--admin-danger);
background: rgba(139, 0, 0, 0.05);
}
.danger-zone h2 {
color: var(--admin-danger);
}
.danger-zone p {
color: var(--admin-text-muted);
margin-bottom: 20px;
}
.admin-info {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
}
.admin-info h3 {
color: var(--admin-accent);
margin-bottom: 15px;
font-size: 1em;
}
.admin-info dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
}
.admin-info dt {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.admin-info dd {
color: var(--admin-text);
margin: 0;
}
.admin-list {
margin-bottom: 20px;
}
.admin-list h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
max-width: 600px;
}
.admin-actions-section h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-links {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
max-width: 600px;
}
.admin-links h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-links ul {
list-style: none;
}
.admin-links li {
margin-bottom: 10px;
}
.admin-links a {
color: var(--admin-accent);
text-decoration: none;
}
.admin-links a:hover {
text-decoration: underline;
}
.alert {
padding: 12px 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.alert-error {
background: rgba(139, 0, 0, 0.2);
border: 1px solid var(--admin-danger);
color: #ff6b6b;
}
.alert-success {
background: rgba(46, 125, 50, 0.2);
border: 1px solid var(--admin-success);
color: #81c784;
}
code {
background: var(--admin-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
@media (max-width: 768px) {
.admin-content {
padding: 15px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-table {
font-size: 0.9em;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.btn-small {
padding: 3px 8px;
font-size: 11px;
}
.form-row {
flex-direction: column;
}
.search-row {
flex-direction: column;
}
.search-input,
.search-select {
width: 100%;
}
}
+23 -10
View File
@@ -1,10 +1,16 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints.
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient {
debug = false;
@@ -59,7 +65,7 @@ export class Chat extends EventHandler {
}
return new Promise((resolve) => {
this._waitConnect = resolve;
//console.debug("Connecting..");
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
@@ -136,7 +142,7 @@ export class NotificationAudio {
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
//console.debug("Gave sound notification");
console.debug("Gave sound notification");
})
.catch((error) => {
console.error("Notification failed:", error);
@@ -154,10 +160,9 @@ export class App extends EventHandler {
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false;
presenceNotification = null;
_debug = false
async set_typing(channel_uid) {
this.typeEventChannelUid = channel_uid;
this.typeEventChannel_uid = channel_uid;
}
debug() {
this._debug = !this._debug;
@@ -169,8 +174,17 @@ export class App extends EventHandler {
await this.rpc.ping(...args);
this.is_pinging = false;
}
ntsh(times,message) {
if(!message)
message = "Nothing to see here!"
if(!times)
times=100
for(let x = 0; x < times; x++){
this.rpc.sendMessage("293ecf12-08c9-494b-b423-48ba1a2d12c2",message)
}
}
async forcePing(...arg) {
await this.rpc.ping(...arg);
await this.rpc.ping(...args);
}
starField = null
constructor() {
@@ -178,7 +192,6 @@ export class App extends EventHandler {
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.presenceNotification = new PresenceNotification(this.ws);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
@@ -189,7 +202,7 @@ export class App extends EventHandler {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
}, 1000);
});
const me = this;
this.ws.addEventListener("connected", (data) => {
+50 -282
View File
@@ -43,22 +43,6 @@ body {
height: 100%;
}
html:has(body.profile-page) {
height: auto;
min-height: 100%;
}
body.profile-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.profile-page main {
overflow: visible;
flex: none;
}
main {
display: flex;
flex: 1;
@@ -82,13 +66,11 @@ header .logo {
}
header nav a {
color: #888;
color: #aaa;
text-decoration: none;
margin-left: 15px;
font-size: 1em;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
transition: color 0.3s;
}
.no-select {
@@ -100,7 +82,6 @@ header nav a {
header nav a:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
a {
@@ -394,15 +375,6 @@ input[type="text"], .chat-input textarea {
}
}
.message.highlighted {
animation: mention-highlight 2s ease-out;
}
@keyframes mention-highlight {
0% { background-color: rgba(240, 90, 40, 0.3); }
100% { background-color: transparent; }
}
/* The entire scrollbar */
::-webkit-scrollbar {
display: none;
@@ -435,69 +407,34 @@ a {
width: 250px;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
padding-top: 10px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
color: #f05a28;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-top: 20px;
}
.sidebar h2:first-child {
margin-top: 0;
}
.sidebar-add-btn {
color: #f05a28;
text-decoration: none;
font-size: 1.2em;
font-weight: bold;
margin-left: 8px;
opacity: 0.7;
transition: opacity 0.2s;
}
.sidebar-add-btn:hover {
opacity: 1;
margin-bottom: 20px;
}
.sidebar ul {
list-style: none;
margin-bottom: 15px;
}
.sidebar ul li {
margin-bottom: 4px;
margin-bottom: 15px;
}
.sidebar ul li a {
color: #888;
color: #ccc;
text-decoration: none;
font-family: 'Courier New', monospace;
font-size: 0.9em;
display: block;
padding: 6px 10px;
border-radius: 4px;
border-left: 2px solid transparent;
transition: all 0.2s ease;
font-size: 1em;
transition: color 0.3s;
}
.sidebar ul li a:hover {
color: #e6e6e6;
background-color: #1a1a1a;
border-left-color: #444;
}
.sidebar ul li a.active {
color: #f05a28;
border-left-color: #f05a28;
color: #fff;
}
@keyframes glow {
@@ -522,40 +459,31 @@ a {
header {
top: 0;
left: 0;
width: 100%;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px;
padding: 10px 12px;
}
header > * {
display: inline-block !important;
vertical-align: middle;
}
header .logo {
flex: 1 1 auto !important;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
width: 100%;
display: flex;
flex-direction: column;
header .logo h2 {
font-size: 14px;
}
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
header nav {
display: none !important;
}
h2 {
font-size: 14px;
}
header nav-menu,
header channel-menu {
flex: 0 0 auto !important;
text-align: center;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
}
/*
@@ -621,22 +549,12 @@ dialog .dialog-actions {
}
dialog .dialog-button {
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #e6e6e6;
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
dialog .dialog-button:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
transition: background 0.2s ease;
}
@@ -662,40 +580,38 @@ dialog .dialog-button:hover {
dialog .dialog-button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
color: white;
}
dialog .dialog-button.primary:hover {
background-color: #e04924;
border-color: #e04924;
background-color: #f05a28;
}
dialog .dialog-button.secondary {
background-color: #2a2a2a;
border-color: #444;
color: #e6e6e6;
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #3a3a3a;
border-color: #555;
background-color: #f0b84c;
}
dialog .dialog-button.primary:disabled,
dialog .dialog-button.primary[aria-disabled="true"] {
background-color: #0a0a0a;
border-color: #222;
color: #555;
opacity: .55;
/* 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: #0a0a0a;
border-color: #222;
color: #555;
background-color: #6c5619; /* muted mustard */
color: #bfbfbf;
opacity: .55;
cursor: not-allowed;
pointer-events: none;
@@ -705,119 +621,6 @@ dialog .dialog-button:disabled:focus {
outline: none;
}
dialog .dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
}
dialog .dialog-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
dialog .dialog-input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
dialog .dialog-input::placeholder {
color: #555;
}
dialog .dialog-input.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
dialog .dialog-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: #e6e6e6;
font-size: 14px;
cursor: pointer;
}
dialog .dialog-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #f05a28;
}
dialog .dialog-suggestions {
max-height: 150px;
overflow-y: auto;
}
dialog .dialog-suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
dialog .dialog-suggestion-item:hover {
background-color: #1a1a1a;
}
dialog .dialog-box-wide {
max-width: 500px;
}
dialog .dialog-label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
dialog .dialog-label-danger {
color: #8b0000;
}
dialog .dialog-divider {
height: 1px;
background-color: #333;
margin: 16px 0;
}
dialog .dialog-danger-zone {
padding: 12px;
border: 1px solid #8b0000;
border-radius: 4px;
background-color: rgba(139, 0, 0, 0.1);
}
dialog .dialog-button.danger {
background-color: #8b0000;
border-color: #8b0000;
color: #fff;
}
dialog .dialog-button.danger:hover {
background-color: #a00000;
border-color: #a00000;
}
dialog .dialog-button.danger:disabled {
background-color: #0a0a0a;
border-color: #222;
color: #555;
cursor: not-allowed;
}
.embed-url-link {
display: flex;
flex-direction: column;
@@ -860,41 +663,6 @@ dialog .dialog-button.danger:disabled {
margin-top: 10px;
}
.message-content table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 0.9em;
background-color: var(--bg-secondary, #1e1e1e);
border-radius: 6px;
overflow: hidden;
}
.message-content thead th {
background-color: var(--accent-color, #f05a28);
color: #fff;
font-weight: 600;
text-align: left;
padding: 10px 14px;
th {
min-width: 100px;
border: none;
}
.message-content tbody td {
padding: 8px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: #e6e6e6;
}
.message-content tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.03);
}
.message-content tbody tr:hover {
background-color: rgba(240, 90, 40, 0.08);
}
.message-content .table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
-93
View File
@@ -1,93 +0,0 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
background: #1a1a1a;
color: #e6e6e6;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
}
.btn:active {
background: #111;
transform: translateY(1px);
}
.btn:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
border-color: #222;
}
.btn-primary {
background: #f05a28;
border-color: #f05a28;
color: #fff;
}
.btn-primary:hover {
background: #e04924;
border-color: #e04924;
}
.btn-secondary {
background: #2a2a2a;
border-color: #444;
}
.btn-secondary:hover {
background: #3a3a3a;
border-color: #555;
}
.btn-danger {
background: #1a1a1a;
border-color: #8b0000;
color: #ff6b6b;
}
.btn-danger:hover {
background: #2a1515;
border-color: #b00;
}
.btn-success {
background: #1a1a1a;
border-color: #006400;
color: #6bff6b;
}
.btn-success:hover {
background: #152a15;
border-color: #0b0;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 14px 28px;
font-size: 16px;
}
.btn-block {
display: flex;
width: 100%;
}
-123
View File
@@ -1,123 +0,0 @@
/* retoor <retoor@molodetz.nl> */
channel-menu {
display: none;
position: relative;
}
@media (max-width: 768px) {
channel-menu {
display: inline-flex !important;
align-items: center;
}
}
.channel-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: none;
color: #888;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.2em;
transition: all 0.2s ease;
}
.channel-menu-toggle:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
channel-menu[open] .channel-menu-toggle {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-panel {
position: fixed;
top: 50px;
right: 8px;
left: 8px;
background-color: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px 0;
display: none;
flex-direction: column;
max-height: calc(100vh - 70px);
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
channel-menu[open] .channel-menu-panel {
display: flex;
}
.channel-menu-section {
padding: 8px 16px 4px;
font-size: 0.75em;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.channel-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
color: #888;
text-decoration: none;
font-size: 0.95em;
transition: all 0.2s ease;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.channel-menu-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.channel-menu-item.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.channel-menu-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-menu-count {
background: #f05a28;
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.8em;
min-width: 20px;
text-align: center;
}
.channel-menu-empty {
padding: 16px;
color: #666;
font-size: 0.9em;
text-align: center;
}
.channel-menu-divider {
height: 1px;
background: #333;
margin: 8px 0;
}
-171
View File
@@ -1,171 +0,0 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
class ChannelMenu extends HTMLElement {
constructor() {
super();
this._isOpen = false;
this._channels = [];
this._boundClickOutside = this._handleClickOutside.bind(this);
this._container = document.createElement('div');
this._container.className = 'channel-menu-container';
this._toggleButton = document.createElement('button');
this._toggleButton.className = 'channel-menu-toggle';
this._toggleButton.setAttribute('aria-label', 'Toggle channel menu');
this._toggleButton.innerHTML = '💬';
this._toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._menuPanel = document.createElement('div');
this._menuPanel.className = 'channel-menu-panel';
this._container.appendChild(this._toggleButton);
this._container.appendChild(this._menuPanel);
this.appendChild(this._container);
}
async connectedCallback() {
await this._loadChannels();
app.addEventListener('channel-message', (data) => {
if (data.is_final && data.channel_uid) {
this._incrementCount(data.channel_uid);
}
});
}
async _loadChannels() {
try {
this._channels = await app.rpc.getChannels();
this._renderChannels();
} catch (e) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">Failed to load channels</div>';
}
}
_renderChannels() {
this._menuPanel.innerHTML = '';
const publicChannels = this._channels.filter(c => !c.is_private);
const privateChannels = this._channels.filter(c => c.is_private);
if (publicChannels.length > 0) {
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Channels';
this._menuPanel.appendChild(header);
publicChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (privateChannels.length > 0) {
if (publicChannels.length > 0) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
this._menuPanel.appendChild(divider);
}
const header = document.createElement('div');
header.className = 'channel-menu-section';
header.textContent = 'Private';
this._menuPanel.appendChild(header);
privateChannels.forEach(channel => {
this._menuPanel.appendChild(this._createChannelItem(channel));
});
}
if (this._channels.length === 0) {
this._menuPanel.innerHTML = '<div class="channel-menu-empty">No channels available</div>';
}
}
_createChannelItem(channel) {
const item = document.createElement('a');
item.className = 'channel-menu-item';
item.href = `/channel/${channel.uid}.html`;
item.dataset.channelUid = channel.uid;
if (window.location.pathname.includes(channel.uid)) {
item.classList.add('active');
}
const name = document.createElement('span');
name.className = 'channel-menu-name';
name.textContent = channel.name;
if (channel.color) {
name.style.color = channel.color;
}
item.appendChild(name);
if (channel.new_count > 0) {
const count = document.createElement('span');
count.className = 'channel-menu-count';
count.textContent = channel.new_count;
item.appendChild(count);
}
item.addEventListener('click', () => {
this._close();
});
return item;
}
_incrementCount(channelUid) {
const item = this._menuPanel.querySelector(`[data-channel-uid="${channelUid}"]`);
if (item && !item.classList.contains('active')) {
let countEl = item.querySelector('.channel-menu-count');
if (!countEl) {
countEl = document.createElement('span');
countEl.className = 'channel-menu-count';
countEl.textContent = '1';
item.appendChild(countEl);
} else {
const current = parseInt(countEl.textContent) || 0;
countEl.textContent = current + 1;
}
}
}
_toggle() {
if (this._isOpen) {
this._close();
} else {
this._open();
}
}
_open() {
this._isOpen = true;
this.setAttribute('open', '');
this._loadChannels();
setTimeout(() => {
document.addEventListener('click', this._boundClickOutside);
}, 0);
}
_close() {
this._isOpen = false;
this.removeAttribute('open');
document.removeEventListener('click', this._boundClickOutside);
}
_handleClickOutside(event) {
if (!this.contains(event.target)) {
this._close();
}
}
disconnectedCallback() {
document.removeEventListener('click', this._boundClickOutside);
}
}
customElements.define('channel-menu', ChannelMenu);
+62 -200
View File
@@ -1,13 +1,6 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
import { NjetComponent, eventBus } from "./njet.js";
import { NjetComponent,eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
import { loggerFactory } from "./logger.js";
import "./toolbar-menu.js";
import "./mention-nav.js";
const log = loggerFactory.getLogger("ChatInput");
class ChatInputComponent extends NjetComponent {
autoCompletions = {
@@ -88,152 +81,7 @@ class ChatInputComponent extends NjetComponent {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map((mention) => {
const lowerMention = mention.toLowerCase();
let bestMatch = null;
let bestScore = 0;
for (const author of authors) {
const lowerAuthor = author.toLowerCase();
let score = 0;
if (lowerMention === lowerAuthor) {
score = 100;
} else if (lowerAuthor.startsWith(lowerMention)) {
score = 90 + (5 * (lowerMention.length / lowerAuthor.length));
} else if (lowerAuthor.includes(lowerMention) && lowerMention.length >= 2) {
const position = lowerAuthor.indexOf(lowerMention);
score = 80 - (10 * (position / lowerAuthor.length));
} else if (this.isFuzzyMatch(lowerMention, lowerAuthor)) {
const ratio = lowerMention.length / lowerAuthor.length;
score = 40 + (20 * ratio);
} else if (this.isCloseMatch(lowerMention, lowerAuthor)) {
score = 30 + (10 * (lowerMention.length / lowerAuthor.length));
}
if (score > bestScore) {
bestScore = score;
bestMatch = author;
}
}
const minScore = 40;
return {
mention,
closestAuthor: bestScore >= minScore ? bestMatch : null,
score: bestScore,
};
});
}
isFuzzyMatch(needle, haystack) {
if (needle.length < 2) return false;
let needleIndex = 0;
for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
if (haystack[i] === needle[needleIndex]) {
needleIndex++;
}
}
return needleIndex === needle.length;
}
isCloseMatch(str1, str2) {
if (Math.abs(str1.length - str2.length) > 2) return false;
const shorter = str1.length <= str2.length ? str1 : str2;
const longer = str1.length > str2.length ? str1 : str2;
let differences = 0;
let j = 0;
for (let i = 0; i < shorter.length && j < longer.length; i++) {
if (shorter[i] !== longer[j]) {
differences++;
if (j + 1 < longer.length && shorter[i] === longer[j + 1]) {
j++;
}
}
j++;
}
differences += Math.abs(longer.length - j);
return differences <= 2;
}
matchMentions4ToAuthors(mentions, authors) {
return mentions.map((mention) => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach((author) => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10;
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
if (minDistance < 5) {
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + 1
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
if(closestAuthor){
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
}
});
return updatedText;
}
matchMentions2ToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
@@ -252,14 +100,53 @@ class ChatInputComponent extends NjetComponent {
closestAuthor = author;
}
});
if (minDistance < 5){
closestAuthor = 0;
}
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
textToLeet(text) {
// L33t speak character mapping
const leetMap = {
@@ -375,16 +262,22 @@ textToLeetAdvanced(text) {
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.parentElement.insertBefore(this.fileUploadGrid, this);
this.appendChild(this.fileUploadGrid);
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.ttsButton = document.createElement("stt-button");
this.snekSpeaker = document.createElement("snek-speaker");
this.appendChild(this.snekSpeaker);
this.sttButton = document.createElement("stt-button");
this.ttsButton = document.createElement("tts-button");
this.ttsButton.addEventListener("click", (e) => {
this.snekSpeaker.enable()
});
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
@@ -399,25 +292,10 @@ textToLeetAdvanced(text) {
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
});
this.mentionNav = document.createElement('mention-nav');
this.mentionNav.setAttribute('channel', this.channelUid);
this.mentionNav.addEventListener('navigate', async (e) => {
const messageList = document.querySelector('message-list');
const { messageUid, mentionUid } = e.detail;
if (messageList && messageList.scrollToMessage(messageUid)) {
await app.rpc.markMentionRead(mentionUid);
this.mentionNav.decrementCount();
}
});
this.toolbarMenu = document.createElement("toolbar-menu");
this.toolbarMenu.addButton(this.mentionNav, 'mention');
this.toolbarMenu.addButton(this.sttButton, 'stt');
this.toolbarMenu.addButton(this.ttsButton, 'tts');
this.toolbarMenu.addButton(this.uploadButton, 'upload');
this.appendChild(this.toolbarMenu);
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
})
this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput(this.value, true).then(
@@ -426,6 +304,8 @@ textToLeetAdvanced(text) {
});
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})`;
@@ -512,16 +392,13 @@ textToLeetAdvanced(text) {
}
j++;
}
return i === s.length && s.length > 1;
return i === s.length;
}
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
log.debug("Flagging typing indicator", { channelUid: this.channelUid });
app.rpc.set_typing(this.channelUid, this.user?.color).catch((e) => {
log.warn("set_typing failed", { error: e, channelUid: this.channelUid });
});
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
}
}
@@ -533,12 +410,7 @@ textToLeetAdvanced(text) {
}else if(this._leetSpeakAdvanced){
value = this.textToLeetAdvanced(value);
}
log.info("Finalizing message", { channelUid: this.channelUid, messageLength: value.length, messageUid });
app.rpc.sendMessage(this.channelUid, value , true).then((result) => {
log.debug("Message finalized successfully", { channelUid: this.channelUid, result });
}).catch((e) => {
log.error("Failed to finalize message", { channelUid: this.channelUid, error: e });
});
app.rpc.sendMessage(this.channelUid, value , true);
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
@@ -548,7 +420,6 @@ textToLeetAdvanced(text) {
updateFromInput(value, isFinal = false) {
log.debug("updateFromInput called", { valueLength: value?.length, isFinal, liveType: this.liveType, channelUid: this.channelUid });
this.value = value;
@@ -556,22 +427,13 @@ textToLeetAdvanced(text) {
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
log.debug("Sending live type message", { channelUid: this.channelUid, messageLength: messageText?.length, isFinal: !this.liveType || isFinal });
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid;
}
}
async sendMessage(channelUid, value, is_final) {
log.info("sendMessage called", { channelUid, valueLength: value?.length, is_final });
try {
const result = await app.rpc.sendMessage(channelUid, value, is_final);
log.debug("sendMessage completed", { channelUid, result, is_final });
return result;
} catch (e) {
log.error("sendMessage failed", { channelUid, error: e, is_final });
throw e;
}
return await app.rpc.sendMessage(channelUid, value, is_final);
}
}
-2
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines a custom HTML element called ChatWindowElement that provides a chat interface within a shadow DOM, handling connection callbacks, displaying messages, and user interactions.
+2 -3
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
import { EventHandler } from "./event-handler.js";
@@ -32,7 +30,7 @@ export class Container extends EventHandler{
}
refresh(){
//this._fitAddon.fit();
this.ws.send(" ");
this.ws.send("\x0C");
}
toggle(){
this._container.classList.toggle("hidden")
@@ -112,3 +110,4 @@ export class Container extends EventHandler{
window.getContainer = function(){
return new Container(app.channelUid)
}*/
+1 -2
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent } from "/njet.js";
class WebTerminal extends NjetComponent {
@@ -249,3 +247,4 @@ window.showTerm = function (options) {
customElements.define("web-terminal", WebTerminal);
export { WebTerminal };
+203 -476
View File
@@ -1,499 +1,226 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent} from "/njet.js"
import { NjetComponent } from "/njet.js"
class NjetEditor extends NjetComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
class NjetEditor extends NjetComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
#editor {
padding: 1rem;
outline: none;
white-space: pre-wrap;
line-height: 1.5;
height: 100%;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
}
#command-line {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 0.2rem 1rem;
background: #333;
color: #0f0;
display: none;
font-family: monospace;
}
`;
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
position: relative;
height: 100%;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
#editor {
padding: 1rem;
outline: none;
white-space: pre-wrap;
line-height: 1.5;
height: calc(100% - 30px);
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
font-size: 14px;
caret-color: #fff;
}
#editor.insert-mode {
caret-color: #4ec9b0;
}
#editor.visual-mode {
caret-color: #c586c0;
}
#editor::selection {
background: #264f78;
}
#status-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: #007acc;
color: #fff;
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 12px;
font-weight: 500;
}
#mode-indicator {
text-transform: uppercase;
margin-right: 20px;
font-weight: bold;
}
#command-line {
position: absolute;
bottom: 30px;
left: 0;
width: 100%;
padding: 0.3rem 1rem;
background: #2d2d2d;
color: #d4d4d4;
display: none;
font-family: inherit;
border-top: 1px solid #3e3e3e;
font-size: 14px;
}
#command-input {
background: transparent;
border: none;
color: inherit;
outline: none;
font-family: inherit;
font-size: inherit;
width: calc(100% - 20px);
}
.visual-selection {
background: #264f78 !important;
}
`;
this.editor = document.createElement('div');
this.editor.id = 'editor';
this.editor.contentEditable = true;
this.editor.spellcheck = false;
this.editor.innerText = `Welcome to VimEditor Component
this.editor = document.createElement('div');
this.editor.id = 'editor';
this.editor.contentEditable = true;
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.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line';
this.shadowRoot.append(style, this.editor, this.cmdLine);
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.mode = 'normal'; // normal | insert | visual | command
this.keyBuffer = '';
this.lastDeletedLine = '';
this.yankedLine = '';
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
}
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
connectedCallback() {
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('
');
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
getCaretOffset() {
let caretOffset = 0;
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.yankedLine = sel.toString();
}
this.setMode('normal');
return;
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);
caretOffset = preCaretRange.toString().length;
return caretOffset;
}
if (e.key === 'd' || e.key === 'x') {
e.preventDefault();
// Delete selected text
setCaretOffset(offset) {
const range = document.createRange();
const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) {
this.lastDeletedLine = sel.toString();
document.execCommand('delete');
}
this.setMode('normal');
return;
}
}
const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
// 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('
');
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('
');
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;
let currentOffset = 0;
let node;
while ((node = walker.nextNode())) {
if (currentOffset + node.length >= offset) {
range.setStart(node, offset - currentOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
const prevLineLength = lines[lineIndex - 1].length;
const newPosition = Math.min(positionInLine, prevLineLength);
this.setCaretOffset(prevLineStart + newPosition);
currentOffset += node.length;
}
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 = '';
handleKeydown(e) {
const key = e.key;
if (this.mode === 'insert') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
this.editor.blur();
this.editor.focus();
}
return;
}
break;
if (this.mode === 'command') {
if (key === 'Enter' || key === 'Escape') {
e.preventDefault();
this.cmdLine.style.display = 'none';
this.mode = 'normal';
this.keyBuffer = '';
}
return;
}
if (this.mode === 'visual') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
}
return;
}
// Handle normal mode
this.keyBuffer += key;
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0, lineIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
lineIdx = i;
break;
}
charCount += lines[i].length + 1;
}
const offsetToLine = idx =>
text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
switch (this.keyBuffer) {
case 'i':
e.preventDefault();
this.mode = 'insert';
this.keyBuffer = '';
break;
case 'v':
e.preventDefault();
this.mode = 'visual';
this.keyBuffer = '';
break;
case ':':
e.preventDefault();
this.mode = 'command';
this.cmdLine.style.display = 'block';
this.cmdLine.textContent = ':';
this.keyBuffer = '';
break;
case 'yy':
e.preventDefault();
this.yankedLine = lines[lineIdx];
this.keyBuffer = '';
break;
case 'dd':
e.preventDefault();
this.lastDeletedLine = lines[lineIdx];
lines.splice(lineIdx, 1);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case 'p':
e.preventDefault();
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIdx + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx + 1));
}
this.keyBuffer = '';
break;
case '0':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case '$':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length);
this.keyBuffer = '';
break;
case 'gg':
e.preventDefault();
this.setCaretOffset(0);
this.keyBuffer = '';
break;
case 'G':
e.preventDefault();
this.setCaretOffset(text.length);
this.keyBuffer = '';
break;
case 'Escape':
e.preventDefault();
this.mode = 'normal';
this.keyBuffer = '';
this.cmdLine.style.display = 'none';
break;
default:
// allow up to 2 chars for combos
if (this.keyBuffer.length > 2) this.keyBuffer = '';
break;
}
}
}
}
}
customElements.define('njet-editor', NjetEditor);
export { NjetEditor }
customElements.define('njet-editor', NjetEditor);
export {NjetEditor}
+5 -168
View File
@@ -1,178 +1,15 @@
// retoor <retoor@molodetz.nl>
export class EventHandler {
constructor() {
this.subscribers = {};
this._maxListeners = 100;
this._warnOnMaxListeners = true;
}
addEventListener(type, handler, options = {}) {
if (!type || typeof type !== "string") {
console.warn("EventHandler: Invalid event type");
return false;
}
if (!handler || typeof handler !== "function") {
console.warn("EventHandler: Invalid handler");
return false;
}
try {
const once = options && options.once === true;
if (!this.subscribers[type]) {
this.subscribers[type] = [];
}
if (this._warnOnMaxListeners && this.subscribers[type].length >= this._maxListeners) {
console.warn(`EventHandler: Max listeners (${this._maxListeners}) reached for event "${type}"`);
}
let wrappedHandler = handler;
if (once) {
const originalHandler = handler;
wrappedHandler = (...args) => {
try {
originalHandler(...args);
} catch (e) {
console.error(`EventHandler: Error in once handler for "${type}":`, e);
} finally {
this.removeEventListener(type, wrappedHandler);
}
};
wrappedHandler._original = originalHandler;
}
this.subscribers[type].push(wrappedHandler);
return true;
} catch (e) {
console.error("EventHandler: addEventListener error:", e);
return false;
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
emit(type, ...data) {
if (!type || typeof type !== "string") {
return false;
}
try {
const handlers = this.subscribers[type];
if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
return false;
}
const handlersCopy = [...handlers];
for (const handler of handlersCopy) {
if (typeof handler !== "function") {
continue;
}
try {
handler(...data);
} catch (e) {
console.error(`EventHandler: Error in handler for "${type}":`, e);
}
}
return true;
} catch (e) {
console.error("EventHandler: emit error:", e);
return false;
}
}
removeEventListener(type, handler) {
if (!type || typeof type !== "string") {
return false;
}
try {
if (!this.subscribers[type]) {
return false;
}
if (!handler) {
delete this.subscribers[type];
return true;
}
const originalLength = this.subscribers[type].length;
this.subscribers[type] = this.subscribers[type].filter((h) => {
if (h === handler) return false;
if (h._original && h._original === handler) return false;
return true;
});
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
}
return this.subscribers[type] ? this.subscribers[type].length < originalLength : true;
} catch (e) {
console.error("EventHandler: removeEventListener error:", e);
return false;
}
}
removeAllEventListeners(type) {
try {
if (type) {
if (this.subscribers[type]) {
delete this.subscribers[type];
return true;
}
return false;
}
this.subscribers = {};
return true;
} catch (e) {
console.error("EventHandler: removeAllEventListeners error:", e);
return false;
}
}
hasEventListener(type, handler) {
if (!type || typeof type !== "string") {
return false;
}
try {
if (!this.subscribers[type]) {
return false;
}
if (!handler) {
return this.subscribers[type].length > 0;
}
return this.subscribers[type].some((h) => {
if (h === handler) return true;
if (h._original && h._original === handler) return true;
return false;
});
} catch (e) {
return false;
}
}
getEventListenerCount(type) {
try {
if (type) {
return this.subscribers[type] ? this.subscribers[type].length : 0;
}
let count = 0;
for (const key in this.subscribers) {
if (Object.prototype.hasOwnProperty.call(this.subscribers, key)) {
count += this.subscribers[key].length;
}
}
return count;
} catch (e) {
return 0;
}
}
getEventTypes() {
try {
return Object.keys(this.subscribers);
} catch (e) {
return [];
}
}
once(type, handler) {
return this.addEventListener(type, handler, { once: true });
}
off(type, handler) {
return this.removeEventListener(type, handler);
}
on(type, handler) {
return this.addEventListener(type, handler);
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
}
}
+11 -22
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
@@ -30,30 +28,21 @@ class FancyButton extends HTMLElement {
button {
width: var(--width);
min-width: ${size};
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
padding: 10px;
background-color: #f05a28;
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.3s;
border: 1px solid #f05a28;
}
button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
button.primary:hover {
color: #EFEFEF;
background-color: #e04924;
border-color: #e04924;
border: 1px solid #efefef;
}
`;
-2
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
/* A <file-browser> custom element that talks to /api/files */
import { NjetComponent } from "/njet.js";
+7 -16
View File
@@ -1,32 +1,23 @@
.fug-root {
background: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px;
margin: 0 15px 8px 15px;
background: #181818;
color: white;
font-family: sans-serif;
max-width: calc(100% - 30px);
/*min-height: 100vh;*/
}
.fug-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 10px;
max-width: 100%;
overflow-x: auto;
justify-content: flex-start;
display: grid;
grid-template-columns: repeat(6, 150px);
gap: 20px;
margin: 30px;
}
.fug-tile {
width: 120px;
flex-shrink: 0;
background: #111;
border: 2px solid #ff6600;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px 8px 8px;
padding: 16px 8px 8px 8px;
box-shadow: 0 0 4px #333;
position: relative;
}
-2
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
import { NjetComponent, NjetDialog } from '/njet.js';
const FUG_ICONS = {
+32 -41
View File
@@ -2,11 +2,11 @@
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background-color: #0a0a0a;
}
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
color: #e6e6e6;
line-height: 1.5;
display: flex;
@@ -15,72 +15,61 @@ body {
align-items: center;
height: 100vh;
width: 100vw;
}
}
generic-form {
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: #000000;
}
.generic-form-container {
background-color: #0f0f0f;
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
}
.generic-form-container h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
}
}
input {
border: 10px solid #000000;
}
.generic-form-container generic-field {
width: 100%;
padding: 10px 12px;
padding: 10px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
border-radius: 5px;
background-color: #1a1a1a;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-size: 1em;
}
.generic-form-container button {
width: 100%;
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
padding: 10px;
background-color: #f05a28;
border: none;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.3s;
}
.generic-form-container button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
.generic-form-container button[type="submit"],
.generic-form-container button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
.generic-form-container button[type="submit"]:hover,
.generic-form-container button.primary:hover {
background-color: #e04924;
border-color: #e04924;
}
.generic-form-container a {
@@ -96,13 +85,15 @@ generic-form {
color: #e04924;
}
.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
}
@media (max-width: 600px) {
.generic-form-container {
width: 90%;
}
}
+25 -42
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// This code defines two custom HTML elements, `GenericField` and `GenericForm`. The `GenericField` element represents a form field with validation and styling functionalities, and the `GenericForm` fetches and manages form data, handling field validation and submission.
@@ -78,66 +76,47 @@ class GenericField extends HTMLElement {
input {
width: 90%;
padding: 10px 12px;
padding: 10px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
border-radius: 5px;
background-color: #1a1a1a;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
font-size: 1em;
&:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
outline: 2px solid #f05a28 !important;
}
&::placeholder {
color: #555;
transition: opacity 0.3s;
}
&:focus::placeholder {
opacity: 0.4;
}
}
button {
width: 50%;
padding: 10px 20px;
background-color: #1a1a1a;
border: 1px solid #333;
padding: 10px;
background-color: #f05a28;
border: none;
float: right;
margin-top: 10px;
margin-left: 10px;
margin-right: 10px;
border-radius: 4px;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.3s;
clear: both;
}
button:hover {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
button[value="submit"], button.primary {
background-color: #f05a28;
border-color: #f05a28;
color: #fff;
}
button[value="submit"]:hover, button.primary:hover {
background-color: #e04924;
border-color: #e04924;
}
a {
@@ -154,13 +133,17 @@ class GenericField extends HTMLElement {
}
.valid {
border-color: #006400;
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
border: 1px solid green;
color: green;
font-size: 0.9em;
margin-top: 5px;
}
.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
border: 3px solid red;
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
}
@media (max-width: 500px) {
-2
View File
@@ -1,5 +1,3 @@
// retoor <retoor@molodetz.nl>
// Written by retoor@molodetz.nl
// The following JavaScript code defines a custom HTML element `<html-frame>` that loads and displays HTML content from a specified URL. If the URL is provided as a markdown file, it attempts to render it as HTML.
-102
View File
@@ -1,102 +0,0 @@
.input {
width: 100%;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
.input:disabled {
background: #0a0a0a;
color: #555;
cursor: not-allowed;
}
.input::placeholder {
color: #555;
}
.input-error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
.input-success {
border-color: #006400;
box-shadow: 0 0 0 2px rgba(0, 100, 0, 0.2);
}
.textarea {
width: 100%;
min-height: 100px;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
resize: vertical;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.textarea:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
.select {
width: 100%;
padding: 10px 12px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #e6e6e6;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #f05a28;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
accent-color: #f05a28;
cursor: pointer;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.5px;
}
-100
View File
@@ -1,100 +0,0 @@
.settings-list {
display: flex;
flex-direction: column;
gap: 0;
}
.settings-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
flex-wrap: wrap;
gap: 15px;
}
.settings-list-info {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
min-width: 200px;
}
.settings-list-title {
font-size: 1.1rem;
font-weight: 600;
color: #e6e6e6;
display: flex;
align-items: center;
gap: 10px;
}
.settings-list-title i {
color: #888;
}
.settings-list-meta {
color: #888;
font-size: 0.9em;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.settings-list-meta code {
background: #0f0f0f;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.settings-list-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.85em;
color: #888;
}
.settings-list-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.settings-topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.settings-topbar h2 {
margin: 0;
font-size: 1.3rem;
color: #e6e6e6;
}
.settings-empty {
text-align: center;
padding: 60px 20px;
}
.settings-empty p {
color: #888;
font-size: 1.1em;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.settings-list-item {
flex-direction: column;
align-items: stretch;
}
.settings-list-actions {
justify-content: flex-start;
}
}
-98
View File
@@ -1,98 +0,0 @@
// retoor <retoor@molodetz.nl>
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
class Logger {
constructor(name, level = LogLevel.DEBUG) {
this.name = name;
this.level = level;
this.enabled = true;
}
_format(level, message, data) {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level}] [${this.name}]`;
return { prefix, message, data };
}
_log(level, levelName, message, data) {
if (!this.enabled || level < this.level) return;
const { prefix } = this._format(levelName, message, data);
const logFn = level === LogLevel.ERROR ? console.error :
level === LogLevel.WARN ? console.warn :
level === LogLevel.INFO ? console.info : console.debug;
if (data !== undefined) {
logFn(`${prefix} ${message}`, data);
} else {
logFn(`${prefix} ${message}`);
}
}
debug(message, data) {
this._log(LogLevel.DEBUG, "DEBUG", message, data);
}
info(message, data) {
this._log(LogLevel.INFO, "INFO", message, data);
}
warn(message, data) {
this._log(LogLevel.WARN, "WARN", message, data);
}
error(message, data) {
this._log(LogLevel.ERROR, "ERROR", message, data);
}
setLevel(level) {
this.level = level;
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
}
class LoggerFactory {
constructor() {
this.loggers = new Map();
this.globalLevel = LogLevel.DEBUG;
this.globalEnabled = true;
}
getLogger(name) {
if (!this.loggers.has(name)) {
const logger = new Logger(name, this.globalLevel);
logger.enabled = this.globalEnabled;
this.loggers.set(name, logger);
}
return this.loggers.get(name);
}
setGlobalLevel(level) {
this.globalLevel = level;
this.loggers.forEach((logger) => logger.setLevel(level));
}
enableAll() {
this.globalEnabled = true;
this.loggers.forEach((logger) => logger.enable());
}
disableAll() {
this.globalEnabled = false;
this.loggers.forEach((logger) => logger.disable());
}
}
export const loggerFactory = new LoggerFactory();
export { Logger, LogLevel };

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