Compare commits

898 Commits
Author SHA1 Message Date
retoor a59bbc213a Update. 2025-12-31 10:57:51 +01:00
retoor 3689775efc Don't send empty dots and spaces 2025-12-31 10:37:15 +01:00
retoor 6250645fa4 Hardcoded tts and stt to english. 2025-12-31 10:32:49 +01:00
retoor 6099fc651c Update. 2025-12-31 10:31:07 +01:00
retoor 74074093dc Fixed STT 2025-12-31 09:59:24 +01:00
retoor 56d0ef01fa Socket upgrade. 2025-12-29 01:25:28 +01:00
retoor 163828e213 feat: add channel management dialogs and styling
feat: implement channel creation, settings, and deletion UI
feat: add RPC methods for channel operations
2025-12-26 02:05:06 +01:00
retoor 401e558011 perf: remove async locks from balancer, socket service, and cache
feat: add db connection injection for testing in app
build: update pytest configuration
2025-12-24 18:03:21 +01:00
retoor 572d9610f2 refactor: enhance socket service with granular locks and async optimizations
refactor: rename scheduled list to tasks in rpc view
2025-12-21 00:25:08 +01:00
retoor 1d3444d665 feat: add configurable registration openness 2025-12-19 12:53:58 +01:00
retoor e0f54fb661 feat: disable open registration and require invitations 2025-12-19 12:34:02 +01:00
retoor 8bacd6aa3f feat: add debug logging option to serve command
refactor: reorganize imports and improve error handling in application
fix: correct typo in ip2location middleware
chore: add author headers to source files
2025-12-18 23:48:50 +01:00
retoor b710008dbe refactor: enhance socket service robustness with locks and error handling
fix: add null checks and safe access utilities to prevent crashes
refactor: restructure socket methods for better concurrency and logging
2025-12-18 22:04:01 +01:00
retoor 70a405b231 fix: ensure socket cleanup in websocket handler 2025-12-17 22:54:25 +01:00
retoor bdc8f149dc refactor: remove presence debounce for instant user departures
refactor: simplify websocket connection and error handling in rpc view
2025-12-17 22:47:21 +01:00
retoor 2e886c7bc1 chore: remove umami analytics script 2025-12-17 22:18:42 +01:00
retoor 1eed22de5c chore: update py files 2025-12-17 22:09:54 +01:00
retoor c23ce6085a feat: add user presence notifications with debounced departures 2025-12-17 21:54:07 +01:00
retoor e798fd50a8 chore: remove umami analytics integration 2025-12-17 18:34:32 +01:00
retoor 48b360bce1 fix: replace safe dictionary access with direct access and add null check for starField 2025-12-17 18:24:08 +01:00
retoor 0e083268c6 Fixed a few bugs. 2025-12-15 00:10:50 +01:00
retoor 0ad8f6cb31 Update. 2025-12-05 19:17:09 +01:00
retoor f8d650567a Update. 2025-12-05 18:16:04 +01:00
retoor 8db6f39046 Update. 2025-12-05 18:15:36 +01:00
retoorandaider e4ebd8b4fd fix: correct syntax errors and remove vulnerable code in WebSocket classes
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:13:36 +01:00
retoorandaider efcd10c3c0 docs: update project quality review with full codebase analysis
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:12:06 +01:00
retoorandaider 2a9b883e1b docs: add project quality review to review.md
Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <aider@aider.chat>
2025-11-06 04:05:42 +01:00
retoor f770dcf2db Update. 2025-11-03 18:08:13 +01:00
retoor 2deb8a2069 Update. 2025-10-28 20:50:53 +01:00
retoor 87e19e3d02 Merge pull request 'Improved paste handling to support selection and providing undo support' (#74) from BordedDev/snek:feature/even-better-paste-handling into main
Reviewed-on: #74
2025-10-05 22:07:18 +02:00
BordedDev 31a754d264 Improved paste handling to support selection and providing undo support 2025-10-05 21:03:35 +02:00
retoor 767048d79b Merge pull request 'Improved video handling in reply' (#73) from BordedDev/snek:bugfix/video-handling-in-reply into main
Reviewed-on: #73
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-10-04 19:05:23 +02:00
retoor 13181fa275 Merge pull request 'Fixed upload handling' (#72) from BordedDev/snek:bugfix/upload-handling into main
Reviewed-on: #72
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-10-04 19:01:54 +02:00
BordedDev 37e872809b Improved video handling in quote 2025-10-04 16:39:00 +02:00
BordedDev b99339ea93 Fixed upload handling 2025-10-04 16:30:54 +02:00
retoor f0e68cb31e Update. 2025-10-02 16:28:38 +02:00
retoor 8fe372532b Update. 2025-09-30 19:54:39 +02:00
retoor 214ac33049 Update. 2025-09-30 19:47:17 +02:00
retoor 7b245869d5 Upate cache. 2025-09-30 19:35:56 +02:00
retoor f5a2928f1b Update, cleanup 2025-09-30 19:28:59 +02:00
retoor f74146bb11 Update cache. 2025-09-30 19:27:07 +02:00
retoor f6157bf879 Update cache. 2025-09-30 19:14:16 +02:00
retoor 704adf6fe8 Update asyncio 2025-09-30 17:36:22 +02:00
retoor f2575e04fb Update. 2025-09-30 17:20:02 +02:00
retoor ba7bbbfc62 Update. 2025-09-30 17:18:48 +02:00
retoor 1db9947267 Update. 2025-09-30 17:17:06 +02:00
retoor 38d05da272 Update logging info. 2025-09-22 14:15:53 +02:00
retoor c740de95f9 Update. 2025-09-22 14:14:33 +02:00
retoor 483a63ede9 Update. 2025-09-22 13:30:53 +02:00
retoor a817b9b61d Update. 2025-09-17 22:55:31 +02:00
retoor 0c6da9845c Update. 2025-09-17 16:30:20 +02:00
retoor 586c653e4f Update. 2025-09-17 16:27:04 +02:00
retoor c8cc411aa5 Min distance. 2025-09-17 16:20:59 +02:00
retoor 71d967114d Added new views. 2025-09-17 16:12:05 +02:00
retoor 659c30f376 Update. 2025-09-17 16:09:48 +02:00
retoor 70d3b3b019 REconnect delay. 2025-09-16 04:46:44 +02:00
retoor 0b8c324944 Update. 2025-09-15 05:00:11 +02:00
retoor cbbaa22f56 Update. 2025-09-15 02:22:03 +02:00
retoor fb754cad92 Update. 2025-09-15 02:12:52 +02:00
retoor 0518cdce0b Update. 2025-09-15 02:09:04 +02:00
retoor 1af739cac2 Update. 2025-09-15 00:58:29 +02:00
retoor b28ba3c47d Update 2025-09-14 22:53:05 +02:00
retoor fadc57a7c7 Update. 2025-09-09 23:40:45 +02:00
retoor cca3946a35 Update channel message. 2025-09-08 06:08:12 +02:00
retoor 18be3fdc19 Executor pools. 2025-09-08 01:09:22 +02:00
retoor 939e63f244 Executor pools. 2025-09-08 00:59:11 +02:00
retoor b4c267d584 Update. 2025-09-07 02:42:47 +02:00
retoor b9b31a494a Update. 2025-08-31 03:41:41 +02:00
retoor b961954aa1 Update. 2025-08-31 03:27:47 +02:00
retoor 84287808c8 Update. 2025-08-31 03:25:26 +02:00
retoor 692272e3ca Fixes. 2025-08-31 03:22:00 +02:00
retoor 89d639e44e New editor. 2025-08-06 15:33:13 +02:00
retoor e62da0aef1 Bugfix. 2025-08-06 14:18:11 +02:00
retoor 3759306e38 Update sentry. 2025-08-06 11:51:58 +02:00
retoor fcd91b4321 Deleted security. 2025-08-03 03:31:08 +02:00
retoor cf32a78ef5 Removed interval. 2025-08-01 02:06:20 +02:00
retoor 7c43d957bc Randomized skeep. 2025-08-01 01:59:23 +02:00
retoor cc6a9ef9d3 Randomized skeep. 2025-08-01 01:57:18 +02:00
retoor 1babfa0d64 Optional replace.: 2025-07-30 14:49:16 +02:00
retoor ce940b39b8 Optional replace.: 2025-07-30 14:46:10 +02:00
retoor 6151fc1dac Optional replace.: 2025-07-30 14:44:25 +02:00
retoor 338bdb5932 Optional replace.: 2025-07-30 14:41:23 +02:00
retoor bbcc845c26 Optional replace.: 2025-07-30 14:36:56 +02:00
retoor 1c080bc4be Big webdav change. 2025-07-30 03:25:36 +02:00
retoor 59b0494328 Update. 2025-07-30 02:51:26 +02:00
retoor 6337350b60 Update. 2025-07-26 23:56:24 +02:00
retoor 986acfac38 Update. 2025-07-25 17:17:45 +02:00
retoor b27149b5ba Update. 2025-07-25 17:10:03 +02:00
retoor eb1284060a Update attributes. 2025-07-25 17:06:15 +02:00
retoor 4266ac1f12 Less stars 2025-07-25 17:06:15 +02:00
retoor 6b4709d011 Merge pull request 'Add support for once event listeners and improve event removal' (#67) from BordedDev/snek:feat/once-event-listener into main
Reviewed-on: #67
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:36:50 +02:00
retoor ef8d3068a8 Merge pull request 'Fix some message rendering' (#68) from BordedDev/snek:bugfix/message-list-rendering into main
Reviewed-on: #68
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:35:49 +02:00
BordedDev a23c14389b Fix some message rendering 2025-07-24 16:14:51 +02:00
BordedDev 6dfd8db0a6 Add support for once event listeners and improve event removal 2025-07-24 15:59:17 +02:00
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
88 changed files with 1330 additions and 9737 deletions
-176
View File
@@ -15,182 +15,6 @@
## 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.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.36.0"
version = "1.14.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
+1 -2
View File
@@ -54,8 +54,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()
-50
View File
@@ -44,7 +44,6 @@ 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
@@ -79,29 +78,6 @@ from snek.view.settings.profile_pages import (
)
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
@@ -323,11 +299,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)
@@ -393,27 +364,6 @@ 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)
-2
View File
@@ -16,7 +16,6 @@ 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
@@ -41,7 +40,6 @@ def get_mappers(app=None):
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
"profile_page": ProfilePageMapper(app=app),
"mention": MentionMapper(app=app),
}
)
-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
-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)
-4
View File
@@ -2,7 +2,6 @@
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
@@ -23,7 +22,6 @@ 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):
@@ -68,6 +66,4 @@ 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"}
-17
View File
@@ -44,14 +44,6 @@ class ChatService(BaseService):
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}")
@@ -105,14 +97,5 @@ class ChatService(BaseService):
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
-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 -1
View File
@@ -70,7 +70,7 @@ class SocketService(BaseService):
self.subscribed_channels.clear()
try:
if self.ws and not self.ws.closed:
await asyncio.wait_for(self.ws.close(), timeout=300.0)
await asyncio.wait_for(self.ws.close(), timeout=5.0)
except asyncio.TimeoutError:
logger.debug("Socket close timed out")
except Exception as ex:
-2
View File
@@ -102,8 +102,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(
-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%;
}
}
+22 -91
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;
@@ -394,15 +378,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;
@@ -522,40 +497,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%;
}
}
/*
@@ -860,41 +826,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;
}
-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);
+10 -22
View File
@@ -4,8 +4,6 @@ import { app } from "./app.js";
import { NjetComponent, eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
import { loggerFactory } from "./logger.js";
import "./toolbar-menu.js";
import "./mention-nav.js";
const log = loggerFactory.getLogger("ChatInput");
@@ -375,7 +373,7 @@ 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");
@@ -383,7 +381,10 @@ textToLeetAdvanced(text) {
this.appendChild(this.textarea);
this.sttButton = document.createElement("stt-button");
this.appendChild(this.sttButton);
this.ttsButton = document.createElement("tts-button");
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
@@ -399,25 +400,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 +412,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})`;
+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;
}
-66
View File
@@ -1,66 +0,0 @@
/* retoor <retoor@molodetz.nl> */
mention-nav {
display: inline-flex;
align-items: center;
}
.mention-nav-button {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 6px 12px;
background: #f05a28;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
font-weight: bold;
font-family: 'Courier New', monospace;
transition: all 0.2s ease;
min-width: 32px;
min-height: 32px;
}
.mention-nav-button:hover {
background: #d94d1f;
transform: scale(1.05);
}
.mention-nav-button:active {
transform: scale(0.98);
}
.mention-nav-badge {
position: absolute;
top: -8px;
right: -8px;
background: #fff;
color: #f05a28;
border-radius: 10px;
padding: 2px 6px;
font-size: 0.7em;
min-width: 18px;
text-align: center;
font-weight: bold;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.mention-nav-button {
padding: 4px 8px;
min-width: 28px;
min-height: 28px;
font-size: 0.9em;
}
.mention-nav-badge {
top: -6px;
right: -6px;
padding: 1px 4px;
font-size: 0.65em;
min-width: 14px;
}
}
-98
View File
@@ -1,98 +0,0 @@
// retoor <retoor@molodetz.nl>
import { app } from "./app.js";
class MentionNav extends HTMLElement {
constructor() {
super();
this._count = 0;
this._channelUid = null;
this._button = null;
this._badge = null;
this._initialized = false;
}
connectedCallback() {
if (!this._initialized) {
this._button = document.createElement('button');
this._button.className = 'mention-nav-button';
this._button.setAttribute('aria-label', 'Go to unread mention');
this._button.innerHTML = '@';
this._button.style.display = 'none';
this._badge = document.createElement('span');
this._badge.className = 'mention-nav-badge';
this._button.appendChild(this._badge);
this._button.addEventListener('click', () => this._handleClick());
this.appendChild(this._button);
this._initialized = true;
}
this._channelUid = this.getAttribute('channel');
this._loadCount();
this._mentionHandler = (data) => {
if (data.data && data.data.channel_uid === this._channelUid) {
this._count++;
this._updateUI();
}
};
app.ws.addEventListener('new_mention', this._mentionHandler);
}
disconnectedCallback() {
if (this._mentionHandler) {
app.ws.removeEventListener('new_mention', this._mentionHandler);
}
}
async _loadCount() {
if (!this._channelUid) return;
try {
this._count = await app.rpc.getUnreadMentionCount(this._channelUid);
this._updateUI();
} catch (e) {
console.warn('Failed to load mention count:', e);
}
}
_updateUI() {
if (this._count > 0) {
this._button.style.display = 'flex';
this._badge.textContent = this._count > 99 ? '99+' : this._count;
} else {
this._button.style.display = 'none';
}
}
async _handleClick() {
try {
const mention = await app.rpc.getNextUnreadMention(this._channelUid);
if (mention) {
this.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
detail: {
messageUid: mention.message_uid,
mentionUid: mention.mention_uid
}
}));
}
} catch (e) {
console.warn('Failed to get next mention:', e);
}
}
decrementCount() {
if (this._count > 0) {
this._count--;
this._updateUI();
}
}
refresh() {
this._loadCount();
}
}
customElements.define('mention-nav', MentionNav);
-14
View File
@@ -259,20 +259,6 @@ class MessageList extends HTMLElement {
}
}
scrollToMessage(messageUid, options = {}) {
const message = this.messageMap.get(messageUid);
if (message) {
message.scrollIntoView({
behavior: options.behavior || 'smooth',
block: 'center'
});
message.classList.add('highlighted');
setTimeout(() => message.classList.remove('highlighted'), 2000);
return true;
}
return false;
}
triggerGlow(uid, color) {
if (color && app.starField) app.starField.glowColor(color);
if (!uid) return;
-246
View File
@@ -1,246 +0,0 @@
// retoor <retoor@molodetz.nl>
class NavMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = false;
this._boundClickOutside = this._handleClickOutside.bind(this);
const style = document.createElement('style');
style.textContent = `
:host {
display: inline-flex;
align-items: center;
position: relative;
}
.nav-container {
position: relative;
}
.menu-toggle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 32px;
height: 32px;
padding: 6px;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.menu-toggle .bar {
width: 18px;
height: 2px;
background-color: #888;
border-radius: 1px;
transition: all 0.2s ease;
}
.menu-toggle:hover .bar {
background-color: #fff;
}
.menu-toggle:hover {
background-color: rgba(255, 255, 255, 0.05);
}
:host([open]) .menu-toggle {
background-color: rgba(255, 255, 255, 0.05);
}
:host([open]) .menu-toggle .bar {
background-color: #fff;
}
:host([open]) .menu-toggle .bar:nth-child(1) {
transform: rotate(45deg) translate(4px, 4px);
}
:host([open]) .menu-toggle .bar:nth-child(2) {
opacity: 0;
}
:host([open]) .menu-toggle .bar:nth-child(3) {
transform: rotate(-45deg) translate(4px, -4px);
}
.menu-panel {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background-color: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 8px 0;
display: none;
flex-direction: column;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
:host([open]) .menu-panel {
display: flex;
}
.menu-item {
display: flex;
align-items: center;
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;
}
.menu-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
.menu-item .icon {
font-size: 1.1em;
}
.menu-item.hidden {
display: none;
}
@media (max-width: 768px) {
.menu-panel {
position: fixed;
top: 50px;
right: 8px;
left: 8px;
margin-top: 0;
min-width: unset;
max-height: calc(100vh - 70px);
overflow-y: auto;
}
}
`;
const container = document.createElement('div');
container.className = 'nav-container';
this._menuPanel = document.createElement('div');
this._menuPanel.className = 'menu-panel';
this._toggleButton = document.createElement('button');
this._toggleButton.className = 'menu-toggle';
this._toggleButton.setAttribute('aria-label', 'Toggle navigation menu');
this._toggleButton.innerHTML = '<span class="bar"></span><span class="bar"></span><span class="bar"></span>';
this._toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._createMenuItems();
container.appendChild(this._menuPanel);
container.appendChild(this._toggleButton);
this.shadowRoot.append(style, container);
}
_createMenuItems() {
const items = [
{ icon: '🏠', label: 'Home', href: '/web.html' },
{ icon: 'đź“‚', label: 'Files', href: '/drive.html' },
{ icon: '🔍', label: 'Search', href: '/search-user.html' },
{ icon: '📥', label: 'Install', href: '#', id: 'install-button', hidden: true },
{ icon: 'đź’¬', label: 'Forum', href: '/forum/index.html' },
{ icon: '⚙️', label: 'Settings', href: '/settings/index.html' },
{ icon: '✉️', label: 'Notifications', href: '#', action: 'notifications' },
{ icon: 'đź”’', label: 'Logout', href: '/logout.html' }
];
items.forEach(item => {
const link = document.createElement('a');
link.className = 'menu-item';
if (item.hidden) {
link.classList.add('hidden');
}
link.href = item.href;
if (item.id) {
link.id = item.id;
this['_' + item.id.replace('-', '')] = link;
}
if (item.action === 'notifications') {
link.addEventListener('click', (e) => {
e.preventDefault();
this._close();
if (typeof window.registerNotificationsServiceWorker === 'function') {
window.registerNotificationsServiceWorker();
}
});
} else if (item.href !== '#') {
link.addEventListener('click', () => {
this._close();
});
}
link.innerHTML = `<span class="icon">${item.icon}</span><span>${item.label}</span>`;
this._menuPanel.appendChild(link);
});
}
_toggle() {
if (this._isOpen) {
this._close();
} else {
this._open();
}
}
_open() {
this._isOpen = true;
this.setAttribute('open', '');
setTimeout(() => {
document.addEventListener('click', this._boundClickOutside);
}, 0);
}
_close() {
this._isOpen = false;
this.removeAttribute('open');
document.removeEventListener('click', this._boundClickOutside);
}
_handleClickOutside(event) {
const path = event.composedPath();
if (!path.includes(this)) {
this._close();
}
}
showInstallButton() {
const installBtn = this.shadowRoot.getElementById('install-button');
if (installBtn) {
installBtn.classList.remove('hidden');
}
}
getInstallButton() {
return this.shadowRoot.getElementById('install-button');
}
disconnectedCallback() {
document.removeEventListener('click', this._boundClickOutside);
}
}
customElements.define('nav-menu', NavMenu);
-293
View File
@@ -1,293 +0,0 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: 'Segoe UI', sans-serif;
background: #0a0a0a;
color: #ccc;
line-height: 1.6;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.site-layout {
display: flex;
min-height: 100vh;
}
.site-sidebar {
width: 200px;
background: #0f0f0f;
padding: 1.5rem;
flex-shrink: 0;
border-right: 1px solid #1a1a1a;
}
.site-sidebar .logo {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
color: #fff;
font-weight: bold;
font-size: 1.1rem;
text-decoration: none;
}
.site-sidebar .logo:hover { text-decoration: none; }
.site-sidebar .logo img {
width: 32px;
height: 32px;
border-radius: 4px;
}
.site-sidebar nav { margin-bottom: 2rem; }
.site-sidebar nav a {
display: block;
padding: 0.5rem 0;
color: #7ef;
font-size: 0.95rem;
border-left: 2px solid transparent;
padding-left: 0.75rem;
margin-left: -0.75rem;
}
.site-sidebar nav a:hover {
color: #fff;
text-decoration: none;
}
.site-sidebar nav a.active {
color: #fff;
border-left-color: #7ef;
}
.site-main {
flex: 1;
padding: 2rem 3rem;
overflow-y: auto;
}
.site-main h1 {
color: #fff;
font-size: 1.75rem;
font-weight: normal;
margin-bottom: 1.5rem;
}
.site-main h2 {
color: #7ef;
font-size: 1.1rem;
font-weight: 600;
margin: 2rem 0 1rem 0;
}
.site-main h3 {
color: #0fa;
font-size: 1rem;
margin: 1.5rem 0 0.75rem 0;
}
.site-main p { margin-bottom: 1rem; }
.site-main ul, .site-main ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.site-main li { margin-bottom: 0.4rem; }
.site-main code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.site-main pre {
background: #111;
border: 1px solid #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #ccc;
}
.site-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #1a1a1a;
}
.site-section:last-child { border-bottom: none; }
.btn {
display: inline-block;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
text-decoration: none;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
background: #1a1a1a;
color: #e6e6e6;
}
.btn:hover {
background: #2a2a2a;
border-color: #444;
color: #fff;
text-decoration: none;
}
.btn-primary {
background: #0d3d3d;
border-color: #7ef;
color: #7ef;
}
.btn-primary:hover {
background: #1a4a4a;
color: #fff;
}
.btn-danger {
border-color: #633;
color: #f66;
}
.btn-danger:hover {
background: #2a1515;
border-color: #844;
}
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 3px;
background: #1a2a2a;
color: #7ef;
}
.badge-green {
background: #1a2a1a;
color: #4a4;
}
.badge-orange {
background: #2a2a1a;
color: #f90;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.card {
background: #111;
border: 1px solid #1a1a1a;
border-radius: 4px;
padding: 1.25rem;
transition: border-color 0.15s;
}
.card:hover { border-color: #333; }
.card h3 {
color: #7ef;
font-size: 1rem;
margin: 0 0 0.5rem 0;
}
.card p {
color: #888;
font-size: 0.9rem;
margin: 0;
}
.table-wrap {
overflow-x: auto;
margin: 1rem 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid #1a1a1a;
}
th {
color: #888;
font-weight: normal;
font-size: 0.85rem;
text-transform: lowercase;
}
td { color: #ccc; }
tr:hover td { background: #111; }
.method-name {
font-family: 'Courier New', monospace;
color: #0fa;
font-weight: bold;
}
.method-sig {
font-family: 'Courier New', monospace;
color: #666;
font-size: 0.9em;
}
@media (max-width: 768px) {
.site-layout { flex-direction: column; }
.site-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #1a1a1a;
padding: 1rem;
}
.site-sidebar nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-sidebar nav a {
border-left: none;
padding: 0.4rem 0.75rem;
margin: 0;
background: #1a1a1a;
border-radius: 4px;
}
.site-sidebar nav a.active {
background: #0d3d3d;
border-left: none;
}
.site-main { padding: 1.5rem; }
}
+1 -1
View File
@@ -24,7 +24,7 @@ export class Socket extends EventHandler {
_maxReconnectAttempts = 50;
_reconnectDelay = 4000;
_pendingCalls = new Map();
_callTimeout = 300000;
_callTimeout = 30000;
_isDestroyed = false;
get isConnected() {
-235
View File
@@ -1,235 +0,0 @@
// retoor <retoor@molodetz.nl>
class ToolbarMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = false;
this._sttButton = null;
this._ttsButton = null;
this._uploadButton = null;
this._observer = null;
this._boundClickOutside = this._handleClickOutside.bind(this);
const style = document.createElement('style');
style.textContent = `
:host {
display: inline-block;
position: relative;
}
.toolbar-container {
position: relative;
}
.menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
margin-left: 10px;
background-color: #000000;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 18px;
position: relative;
overflow: visible;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.menu-toggle:hover {
background-color: #222222;
}
.menu-toggle:active {
transform: scale(0.95);
}
:host([open]) .menu-toggle {
background-color: #222222;
}
.status-indicator {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
border-radius: 50%;
display: none;
}
.status-indicator.listening {
display: block;
background-color: #d32f2f;
animation: pulse-indicator 1.5s ease-in-out infinite;
}
.status-indicator.tts-enabled {
display: block;
background-color: #2e7d32;
}
@keyframes pulse-indicator {
0%, 100% {
box-shadow: 0 0 0 0 rgba(211, 47, 47, 0.6);
}
50% {
box-shadow: 0 0 0 4px rgba(211, 47, 47, 0);
}
}
.menu-panel {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background-color: #111111;
border: 1px solid #333333;
border-radius: 8px;
padding: 8px;
display: none;
flex-direction: column;
gap: 8px;
min-width: 60px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
:host([open]) .menu-panel {
display: flex;
}
.menu-item {
display: flex;
justify-content: center;
}
`;
const container = document.createElement('div');
container.className = 'toolbar-container';
this._menuPanel = document.createElement('div');
this._menuPanel.className = 'menu-panel';
this._toggleButton = document.createElement('button');
this._toggleButton.className = 'menu-toggle';
this._toggleButton.setAttribute('aria-label', 'Toggle toolbar menu');
this._toggleButton.innerHTML = '&#9776;';
this._statusIndicator = document.createElement('span');
this._statusIndicator.className = 'status-indicator';
this._toggleButton.appendChild(this._statusIndicator);
this._toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
container.appendChild(this._menuPanel);
container.appendChild(this._toggleButton);
this.shadowRoot.append(style, container);
}
_toggle() {
if (this._isOpen) {
this._close();
} else {
this._open();
}
}
_open() {
this._isOpen = true;
this.setAttribute('open', '');
setTimeout(() => {
document.addEventListener('click', this._boundClickOutside);
}, 0);
}
_close() {
this._isOpen = false;
this.removeAttribute('open');
document.removeEventListener('click', this._boundClickOutside);
}
_handleClickOutside(event) {
const path = event.composedPath();
if (!path.includes(this)) {
this._close();
}
}
_observeButtonStates() {
if (this._observer) {
this._observer.disconnect();
}
this._observer = new MutationObserver(() => {
this._updateStatusIndicator();
});
if (this._sttButton) {
this._observer.observe(this._sttButton, {
attributes: true,
attributeFilter: ['listening']
});
}
if (this._ttsButton) {
this._observer.observe(this._ttsButton, {
attributes: true,
attributeFilter: ['enabled']
});
}
this._updateStatusIndicator();
}
_updateStatusIndicator() {
this._statusIndicator.classList.remove('listening', 'tts-enabled');
if (this._sttButton?.hasAttribute('listening')) {
this._statusIndicator.classList.add('listening');
} else if (this._ttsButton?.hasAttribute('enabled')) {
this._statusIndicator.classList.add('tts-enabled');
}
}
addButton(button, type) {
const wrapper = document.createElement('div');
wrapper.className = 'menu-item';
if (button.style) {
button.style.marginLeft = '0';
}
wrapper.appendChild(button);
this._menuPanel.appendChild(wrapper);
if (type === 'stt') {
this._sttButton = button;
} else if (type === 'tts') {
this._ttsButton = button;
} else if (type === 'upload') {
this._uploadButton = button;
}
this._observeButtonStates();
}
connectedCallback() {
this._observeButtonStates();
}
disconnectedCallback() {
document.removeEventListener('click', this._boundClickOutside);
if (this._observer) {
this._observer.disconnect();
}
}
}
customElements.define('toolbar-menu', ToolbarMenu);
+1 -2
View File
@@ -7,7 +7,6 @@ from app.cache import time_cache_async, time_cache
from mistune import HTMLRenderer, Markdown
from mistune.plugins.formatting import strikethrough
from mistune.plugins.spoiler import spoiler
from mistune.plugins.table import table
from mistune.plugins.url import url
from pygments import highlight
from pygments.formatters import html
@@ -81,7 +80,7 @@ class MarkdownRenderer(HTMLRenderer):
def render_markdown_sync(app, markdown_string):
renderer = MarkdownRenderer(app, None)
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler, table])
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
return markdown(markdown_string)
+1 -7
View File
@@ -145,14 +145,8 @@
<div class="container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek Home Logo" />
Snek
Snek Community
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html" class="active">About</a>
</div>
</nav>
<header class="container about-hero">
-18
View File
@@ -1,18 +0,0 @@
{% extends "app.html" %}
{% block body_class %}admin-page{% endblock %}
{% block sidebar %}
{% include "admin/sidebar.html" %}
{% endblock %}
{% block head %}
<link rel="stylesheet" href="/admin.css">
{% endblock %}
{% block logo %}
<h1>Admin Panel</h1>
{% endblock %}
{% block main %}
{% endblock main %}
@@ -1,26 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Clear Channel History</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<p>This action will permanently delete all messages in this channel. This cannot be undone.</p>
<form method="post" action="/admin/channels/{{ channel.uid }}/clear.html">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Clear All Messages</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
{% endblock main %}
@@ -1,26 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Delete Channel</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<p>This action will permanently delete this channel and all its messages. This cannot be undone.</p>
<form method="post" action="/admin/channels/{{ channel.uid }}/delete.html">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Delete Channel</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
{% endblock main %}
@@ -1,53 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Edit Channel</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/channels/{{ channel.uid }}/edit.html">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ channel.name }}" maxlength="50" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3" maxlength="500">{{ channel.description or '' }}</textarea>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_private" {% if channel.is_private %}checked{% endif %}>
Private Channel
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
<section class="admin-info">
<h3>Channel Information</h3>
<dl>
<dt>UID</dt>
<dd>{{ channel.uid }}</dd>
<dt>Type</dt>
<dd>{{ channel.tag or 'channel' }}</dd>
<dt>Created</dt>
<dd>{{ channel.created_at }}</dd>
</dl>
</section>
</div>
{% endblock main %}
@@ -1,70 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Channels</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/channels/index.html">
<input type="text" name="search" placeholder="Search channels..." value="{{ search }}" class="search-input">
<button type="submit" class="btn btn-primary">Search</button>
{% if search %}
<a href="/admin/channels/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Private</th>
<th>Members</th>
<th>Messages</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr>
<td>{{ channel.name }}</td>
<td>{{ channel.tag or 'channel' }}</td>
<td>{% if channel.is_private %}Yes{% else %}No{% endif %}</td>
<td>{{ channel.member_count }}</td>
<td>{{ channel.message_count }}</td>
<td>{{ channel.created_at }}</td>
<td class="actions">
<a href="/admin/channels/{{ channel.uid }}/members.html" class="btn btn-small">Members</a>
<a href="/admin/channels/{{ channel.uid }}/edit.html" class="btn btn-small">Edit</a>
<a href="/admin/channels/{{ channel.uid }}/clear.html" class="btn btn-small btn-warning">Clear</a>
<a href="/admin/channels/{{ channel.uid }}/delete.html" class="btn btn-small btn-danger">Delete</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No channels found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} channels</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/channels/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/channels/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}
@@ -1,48 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Channel Members</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ channel.name }}</h2>
<p>{{ channel.description }}</p>
<a href="/admin/channels/index.html" class="btn btn-secondary">Back to Channels</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Nick</th>
<th>Owner</th>
<th>Banned</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr class="{% if member.is_banned %}row-banned{% endif %}">
<td>{{ member.username }}</td>
<td>{{ member.nick }}</td>
<td>{% if member.is_owner %}Yes{% else %}No{% endif %}</td>
<td>{% if member.is_banned %}Yes{% else %}No{% endif %}</td>
<td>{{ member.joined_at }}</td>
<td class="actions">
<a href="/admin/users/{{ member.user_uid }}/edit.html" class="btn btn-small">Edit User</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No members found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}
-67
View File
@@ -1,67 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Drives</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/files/index.html">
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if user_uid %}
<a href="/admin/files/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Items</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for drive in drives %}
<tr>
<td>{{ drive.name }}</td>
<td><a href="/admin/users/{{ drive.user_uid }}/edit.html">{{ drive.user_nick }}</a></td>
<td>{{ drive.item_count }}</td>
<td>{{ drive.created_at }}</td>
<td class="actions">
<a href="/admin/files/{{ drive.uid }}/items.html" class="btn btn-small">View Items</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No drives found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/index.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/index.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
-63
View File
@@ -1,63 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Drive Items</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ drive.name or 'Drive' }}</h2>
<p>Owner: {{ user.nick if user else 'Unknown' }}</p>
<a href="/admin/files/index.html" class="btn btn-secondary">Back to Drives</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Type</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="path-preview">{{ item.path }}</td>
<td>{{ item.size }}</td>
<td>{{ item.mime_type }}</td>
<td>{{ item.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/files/{{ drive.uid }}/items.html" style="display:inline;">
<input type="hidden" name="item_uid" value="{{ item.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this item?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No items found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
@@ -1,70 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Repositories</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/files/repositories.html">
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if user_uid %}
<a href="/admin/files/repositories.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Private</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for repo in repositories %}
<tr>
<td>{{ repo.name }}</td>
<td><a href="/admin/users/{{ repo.user_uid }}/edit.html">{{ repo.user_nick }}</a></td>
<td>{% if repo.is_private %}Yes{% else %}No{% endif %}</td>
<td>{{ repo.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/files/repositories.html?{% if user_uid %}user_uid={{ user_uid }}{% endif %}" style="display:inline;">
<input type="hidden" name="repo_uid" value="{{ repo.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this repository?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No repositories found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/repositories.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/repositories.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
@@ -1,64 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Forums</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Active</th>
<th>Threads</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for forum in forums %}
<tr class="{% if not forum.is_active %}row-inactive{% endif %}">
<td>{{ forum.name }}</td>
<td class="description-preview">{{ forum.description }}</td>
<td>{% if forum.is_active %}Yes{% else %}No{% endif %}</td>
<td>{{ forum.thread_count }}</td>
<td>{{ forum.created_at }}</td>
<td class="actions">
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-small">Threads</a>
<form method="post" action="/admin/forums/index.html" style="display:inline;">
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
<input type="hidden" name="action" value="toggle_active">
<button type="submit" class="btn btn-small">{% if forum.is_active %}Deactivate{% else %}Activate{% endif %}</button>
</form>
<form method="post" action="/admin/forums/index.html" style="display:inline;">
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this forum?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No forums found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
@@ -1,60 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Posts</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ thread.title }}</h2>
<p>Forum: {{ forum.name }}</p>
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-secondary">Back to Threads</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Author</th>
<th>Content</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td><a href="/admin/users/{{ post.created_by_uid }}/edit.html">{{ post.created_by_nick }}</a></td>
<td class="message-preview">{{ post.content }}</td>
<td>{{ post.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/forums/threads/{{ thread.uid }}/posts.html" style="display:inline;">
<input type="hidden" name="post_uid" value="{{ post.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this post?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">No posts found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
@@ -1,77 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Threads</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ forum.name }}</h2>
<p>{{ forum.description }}</p>
<a href="/admin/forums/index.html" class="btn btn-secondary">Back to Forums</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Pinned</th>
<th>Locked</th>
<th>Posts</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for thread in threads %}
<tr class="{% if thread.is_pinned %}row-pinned{% endif %} {% if thread.is_locked %}row-locked{% endif %}">
<td>{{ thread.title }}</td>
<td><a href="/admin/users/{{ thread.created_by_uid }}/edit.html">{{ thread.created_by_nick }}</a></td>
<td>{% if thread.is_pinned %}Yes{% else %}No{% endif %}</td>
<td>{% if thread.is_locked %}Yes{% else %}No{% endif %}</td>
<td>{{ thread.post_count }}</td>
<td>{{ thread.created_at }}</td>
<td class="actions">
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html" class="btn btn-small">Posts</a>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="toggle_pin">
<button type="submit" class="btn btn-small">{% if thread.is_pinned %}Unpin{% else %}Pin{% endif %}</button>
</form>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="toggle_lock">
<button type="submit" class="btn btn-small">{% if thread.is_locked %}Unlock{% else %}Lock{% endif %}</button>
</form>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this thread?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No threads found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
-88
View File
@@ -1,88 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Dashboard</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-stats">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card stat-highlight">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ stats.users }}</span>
<span class="stat-sub">{{ stats.online }} currently online</span>
</div>
<div class="stat-card">
<span class="stat-label">Channels</span>
<span class="stat-value">{{ stats.channels }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Messages</span>
<span class="stat-value">{{ stats.messages }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Notifications</span>
<span class="stat-value">{{ stats.notifications }}</span>
</div>
</div>
<h3>Forums</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Forums</span>
<span class="stat-value">{{ stats.forums }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Threads</span>
<span class="stat-value">{{ stats.threads }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Posts</span>
<span class="stat-value">{{ stats.posts }}</span>
</div>
</div>
<h3>Storage</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">User Drives</span>
<span class="stat-value">{{ stats.drives }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Git Repositories</span>
<span class="stat-value">{{ stats.repositories }}</span>
</div>
</div>
</section>
<section class="admin-activity">
<h2>Recent Messages</h2>
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Channel</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for item in activity %}
<tr>
<td><a href="/admin/users/{{ item.user_uid }}/edit.html">{{ item.user_nick }}</a></td>
<td><a href="/admin/channels/{{ item.channel_uid }}/edit.html">{{ item.channel_name }}</a></td>
<td class="message-preview">{{ item.content }}</td>
<td class="time-cell">{{ item.created_at }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No recent activity</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}
@@ -1,80 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Messages</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/messages/index.html">
<div class="search-row">
<input type="text" name="query" placeholder="Search messages..." value="{{ query }}" class="search-input">
<select name="channel_uid" class="search-select">
<option value="">All Channels</option>
{% for channel in channels %}
<option value="{{ channel.uid }}" {% if channel.uid == channel_uid %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Search</button>
{% if query or channel_uid or user_uid %}
<a href="/admin/messages/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</div>
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Channel</th>
<th>Message</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for message in messages %}
<tr>
<td><a href="/admin/users/{{ message.user_uid }}/edit.html">{{ message.user_nick }}</a></td>
<td><a href="/admin/channels/{{ message.channel_uid }}/edit.html">{{ message.channel_name }}</a></td>
<td class="message-preview">{{ message.content }}</td>
<td>{{ message.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/messages/index.html?{{ request.query_string }}" style="display:inline;">
<input type="hidden" name="message_uid" value="{{ message.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this message?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No messages found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} messages</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/messages/index.html?page={{ page - 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/messages/index.html?page={{ page + 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}
@@ -1,65 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Create Notification</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>Mass Notification</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/notifications/create.html">
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" maxlength="500" required placeholder="Enter notification message...">{{ message or '' }}</textarea>
<small>Maximum 500 characters</small>
</div>
<div class="form-group">
<label>Recipients</label>
<div class="radio-group">
<label>
<input type="radio" name="target" value="all" checked>
All Users
</label>
<label>
<input type="radio" name="target" value="selected">
Selected Users
</label>
</div>
</div>
<div class="form-group" id="user-select-group" style="display:none;">
<label>Select Users</label>
<div class="user-checkboxes">
{% for user in users %}
<label class="checkbox-label">
<input type="checkbox" name="user_uids" value="{{ user.uid }}">
{{ user.nick }} ({{ user.username }})
</label>
{% endfor %}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Send Notification</button>
<a href="/admin/notifications/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
<script>
document.querySelectorAll('input[name="target"]').forEach(function(radio) {
radio.addEventListener('change', function() {
var userGroup = document.getElementById('user-select-group');
userGroup.style.display = this.value === 'selected' ? 'block' : 'none';
});
});
</script>
{% endblock main %}
@@ -1,55 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Notifications</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<a href="/admin/notifications/create.html" class="btn btn-primary">Create Mass Notification</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Type</th>
<th>Message</th>
<th>Read</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for notif in notifications %}
<tr class="{% if notif.is_read %}row-read{% endif %}">
<td><a href="/admin/users/{{ notif.user_uid }}/edit.html">{{ notif.user_nick }}</a></td>
<td>{{ notif.type }}</td>
<td class="message-preview">{{ notif.message }}</td>
<td>{% if notif.is_read %}Yes{% else %}No{% endif %}</td>
<td>{{ notif.created_at }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No notifications found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/notifications/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/notifications/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}
-20
View File
@@ -1,20 +0,0 @@
<aside class="sidebar admin-sidebar" id="channelSidebar">
<h2>Admin</h2>
<ul>
<li><a class="no-select" href="/admin/index.html">Dashboard</a></li>
<li><a class="no-select" href="/admin/users/index.html">Users</a></li>
<li><a class="no-select" href="/admin/channels/index.html">Channels</a></li>
<li><a class="no-select" href="/admin/messages/index.html">Messages</a></li>
<li><a class="no-select" href="/admin/forums/index.html">Forums</a></li>
<li><a class="no-select" href="/admin/files/index.html">Files</a></li>
<li><a class="no-select" href="/admin/files/repositories.html">Repositories</a></li>
<li><a class="no-select" href="/admin/notifications/index.html">Notifications</a></li>
<li><a class="no-select" href="/admin/system/index.html">System</a></li>
<li><a class="no-select" href="/admin/system/kv.html">KV Store</a></li>
<li><a class="no-select" href="/admin/system/push.html">Push</a></li>
</ul>
<hr>
<ul>
<li><a class="no-select" href="/settings/index.html">Back to Settings</a></li>
</ul>
</aside>
@@ -1,52 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>System</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<section class="admin-stats">
<h2>System Information</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Uptime</span>
<span class="info-value">{{ uptime }}</span>
</div>
<div class="info-item">
<span class="info-label">Total Users</span>
<span class="info-value">{{ stats.users }}</span>
</div>
<div class="info-item">
<span class="info-label">Online Users</span>
<span class="info-value">{{ stats.online }}</span>
</div>
<div class="info-item">
<span class="info-label">Total Messages</span>
<span class="info-value">{{ stats.messages }}</span>
</div>
</div>
</section>
<section class="admin-actions-section">
<h2>Maintenance</h2>
<form method="post" action="/admin/system/index.html">
<input type="hidden" name="action" value="maintenance">
<p>Run database maintenance (VACUUM and ANALYZE) to optimize performance.</p>
<button type="submit" class="btn btn-primary">Run Maintenance</button>
</form>
</section>
<section class="admin-links">
<h2>Quick Links</h2>
<ul>
<li><a href="/admin/system/kv.html">KV Store Editor</a></li>
<li><a href="/admin/system/push.html">Push Registrations</a></li>
</ul>
</section>
</div>
{% endblock main %}
-65
View File
@@ -1,65 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>KV Store</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<section class="admin-form">
<h2>Add / Update Entry</h2>
<form method="post" action="/admin/system/kv.html">
<input type="hidden" name="action" value="set">
<div class="form-row">
<div class="form-group">
<label for="key">Key</label>
<input type="text" id="key" name="key" maxlength="255" required placeholder="key_name">
</div>
<div class="form-group">
<label for="value">Value</label>
<input type="text" id="value" name="value" maxlength="10000" required placeholder="value">
</div>
<div class="form-group form-actions-inline">
<button type="submit" class="btn btn-primary">Set</button>
</div>
</div>
</form>
</section>
<section class="admin-list">
<h2>Current Entries</h2>
<table class="admin-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td><code>{{ entry.key }}</code></td>
<td class="value-preview">{{ entry.value }}</td>
<td class="actions">
<form method="post" action="/admin/system/kv.html" style="display:inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="key" value="{{ entry.key }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this entry?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3">No entries found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}
-53
View File
@@ -1,53 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Push Registrations</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Endpoint</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reg in registrations %}
<tr>
<td><a href="/admin/users/{{ reg.user_uid }}/edit.html">{{ reg.user_nick }}</a></td>
<td class="endpoint-preview">{{ reg.endpoint }}</td>
<td>{{ reg.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/system/push.html" style="display:inline;">
<input type="hidden" name="registration_uid" value="{{ reg.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this registration?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">No push registrations found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/system/push.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/system/push.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}
-37
View File
@@ -1,37 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>{% if target_user.is_banned %}Unban{% else %}Ban{% endif %} User</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ target_user.username }} ({{ target_user.nick }})</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if target_user.is_banned %}
<p>This user is currently banned. Unbanning will restore their access to the system.</p>
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
<input type="hidden" name="action" value="unban">
<div class="form-actions">
<button type="submit" class="btn btn-primary">Unban User</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
{% else %}
<p>Banning this user will prevent them from logging in and accessing the system.</p>
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
<input type="hidden" name="action" value="ban">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Ban User</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
{% endif %}
</section>
</div>
{% endblock main %}
-64
View File
@@ -1,64 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Edit User</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>{{ target_user.username }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/users/{{ target_user.uid }}/edit.html">
<div class="form-group">
<label for="nick">Nickname</label>
<input type="text" id="nick" name="nick" value="{{ target_user.nick }}" maxlength="20" required>
</div>
<div class="form-group">
<label for="color">Color</label>
<input type="color" id="color" name="color" value="{{ target_user.color or '#ffffff' }}">
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_admin" {% if target_user.is_admin %}checked{% endif %}>
Administrator
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_banned" {% if target_user.is_banned %}checked{% endif %}>
Banned
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
<section class="admin-info">
<h3>User Information</h3>
<dl>
<dt>UID</dt>
<dd>{{ target_user.uid }}</dd>
<dt>Created</dt>
<dd>{{ target_user.created_at }}</dd>
<dt>Last Online</dt>
<dd>{{ target_user.last_online or 'Never' }}</dd>
<dt>Location</dt>
<dd>{{ target_user.city }}{% if target_user.country_long %}, {{ target_user.country_long }}{% endif %}</dd>
<dt>IP</dt>
<dd>{{ target_user.ip or 'Unknown' }}</dd>
</dl>
</section>
</div>
{% endblock main %}
-68
View File
@@ -1,68 +0,0 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Users</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/users/index.html">
<input type="text" name="search" placeholder="Search users..." value="{{ search }}" class="search-input">
<button type="submit" class="btn btn-primary">Search</button>
{% if search %}
<a href="/admin/users/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Nick</th>
<th>Admin</th>
<th>Banned</th>
<th>Location</th>
<th>Last Online</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{% if user.is_banned %}row-banned{% endif %}">
<td>{{ user.username }}</td>
<td>{{ user.nick }}</td>
<td>{% if user.is_admin %}Yes{% else %}No{% endif %}</td>
<td>{% if user.is_banned %}Yes{% else %}No{% endif %}</td>
<td>{{ user.city }}{% if user.country_short %}, {{ user.country_short }}{% endif %}</td>
<td>{{ user.last_online or 'Never' }}</td>
<td class="actions">
<a href="/admin/users/{{ user.uid }}/edit.html" class="btn btn-small">Edit</a>
<a href="/admin/users/{{ user.uid }}/ban.html" class="btn btn-small btn-danger">{% if user.is_banned %}Unban{% else %}Ban{% endif %}</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No users found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} users</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/users/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/users/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}
+25 -21
View File
@@ -13,8 +13,6 @@
<script src="/file-upload-grid.js" type="module"></script>
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
<script src="/nav-menu.js" type="module"></script>
<script src="/channel-menu.js" type="module"></script>
<script src="/push.js" type="module"></script>
<script src="/fancy-button.js" type="module"></script>
<script src="/upload-button.js" type="module"></script>
@@ -36,17 +34,23 @@
<link rel="stylesheet" href="/buttons.css">
<link rel="stylesheet" href="/inputs.css">
<link rel="stylesheet" href="/lists.css">
<link rel="stylesheet" href="/channel-menu.css">
<link rel="stylesheet" href="/mention-nav.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
{% block head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<body>
<header>
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
<channel-menu></channel-menu>
<nav-menu></nav-menu>
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
<a class="no-select" href="/web.html">🏠</a>
<a class="no-select" href="/drive.html">đź“‚</a>
<a class="no-select" href="/search-user.html">🔍</a>
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
<a class="no-select" href="/forum/index.html">đź’¬</a>
<a class="no-select" href="/settings/index.html">⚙️</a>
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
<a class="no-select" href="/logout.html">đź”’</a>
</nav>
</header>
{% block sidebar %}
@@ -148,21 +152,21 @@ app.starField.renderWord("H4x0r 1337")
}
})
let installPrompt = null
let installPrompt = null
window.addEventListener("beforeinstallprompt", (e) => {
//e.preventDefault();
installPrompt = e;
const navMenu = document.querySelector('nav-menu');
if (navMenu) {
navMenu.showInstallButton();
const button = navMenu.getInstallButton();
if (button) {
button.addEventListener("click", async (evt) => {
evt.preventDefault();
const result = await installPrompt.prompt();
});
}
}
})
//document.addEventListener("DOMContentLoaded", () => {
const button = document.getElementById("install-button")
button.style.display = 'inline-block'
button.addEventListener("click", async ()=>{
const result = await installPrompt.prompt()
})
})
;
</script>
+7 -287
View File
@@ -1,290 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Documentation - Snek Platform</title>
<meta name="description" content="Snek platform documentation hub. Learn about architecture, API, bot development, and how to contribute." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero img {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto;
}
.doc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.doc-card {
background: #181818;
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.15s, box-shadow 0.15s;
display: block;
text-decoration: none;
}
.doc-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
text-decoration: none;
}
.doc-card h2 {
color: #7ef;
margin-bottom: 0.75rem;
font-size: 1.25rem;
}
.doc-card p {
color: #aaa;
font-size: 0.95rem;
margin: 0;
}
.doc-card .arrow {
color: #0fa;
float: right;
font-size: 1.2rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section p {
margin-bottom: 1rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.section pre {
background: #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #7ef;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.doc-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html" class="active">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
{% extends "base.html" %}
<header class="container page-hero">
<img src="/image/snek_logo_256x256.png" alt="Snek Logo" />
<h1>Documentation</h1>
<p>Learn how to use, extend, and contribute to the Snek platform</p>
</header>
{% block main %}
<div class="dialog">
<main class="container">
<nav class="doc-grid">
<a href="/architecture.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>Architecture</h2>
<p>Understand the technical architecture: backend services, frontend modules, database design, and real-time communication.</p>
</a>
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
<html-frame url="/docs.md"></html-frame>
<a href="/design.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>Design Decisions</h2>
<p>Learn why specific technologies were chosen and the philosophy behind the platform's design.</p>
</a>
<a href="/api.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>API Reference</h2>
<p>Complete WebSocket RPC API documentation with all available methods for channels, messages, and more.</p>
</a>
<a href="/bots.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>Bot Development</h2>
<p>Build automated bots with complete Python and JavaScript examples. Integrate with AI services.</p>
</a>
<a href="/contribute.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>Contribute</h2>
<p>Set up development environment, understand project structure, and learn how to add features.</p>
</a>
<a href="/about.html" class="doc-card">
<span class="arrow">&rarr;</span>
<h2>About</h2>
<p>Learn about the platform's mission, principles, and why it exists.</p>
</a>
</nav>
<section class="section">
<h2>Quick Start</h2>
<p>Get Snek running locally in under a minute:</p>
<pre>pip install git+https://retoor.molodetz.nl/retoor/snek.git
snek serve</pre>
<p>The server starts at <code>http://localhost:8080</code>. Register a user and start chatting.</p>
</section>
<section class="section">
<h2>Key Features</h2>
<p>Snek is a privacy-focused platform for developers:</p>
<ul style="list-style: disc inside; margin-bottom: 1rem;">
<li>Real-time chat with WebSocket communication</li>
<li>File sharing via WebDAV and SFTP</li>
<li>Git repository hosting</li>
<li>In-browser Ubuntu terminal</li>
<li>AI integration for chat assistance</li>
<li>Bot development support</li>
<li>No email required, no activity logging</li>
<li>Self-hosting with single command deployment</li>
</ul>
</section>
<section class="section">
<h2>Connection Details</h2>
<p>Access the platform via multiple protocols:</p>
<ul style="list-style: none; margin-bottom: 1rem;">
<li><strong>WebDAV:</strong> <code>davs://molodetz.online/webdav</code></li>
<li><strong>SFTP:</strong> <code>sftp://molodetz.online:2242</code></li>
<li><strong>Git:</strong> <code>git@molodetz.online:username/repo.git</code></li>
<li><strong>WebSocket:</strong> <code>wss://snek.community/rpc.ws</code></li>
</ul>
<p>All protocols use your Snek credentials.</p>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
</div>
{% endblock %}
+1 -2
View File
@@ -168,8 +168,7 @@
<p>Professional Platform for Developers, Testers &amp; AI Professionals</p>
<a href="/login.html" class="btn">Login</a>
<a href="/register.html" class="btn btn-primary">Register</a>
<a href="/docs.html" class="btn">Docs</a>
<a href="/about.html" class="btn">About</a>
<a href="/about.html" class="about-link">About</a>
</header>
<main class="container">
<section id="features" class="grid">
-2
View File
@@ -1,7 +1,5 @@
{% extends "app.html" %}
{% block body_class %}profile-page{% endblock %}
{% block sidebar %}
<aside class="sidebar" id="channelSidebar">
<h2>Navigation</h2>
+1 -1
View File
@@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
const starField = new StarField({count: 200});
const starField = new StarField({starCount: 50});
app.starField = starField;
class DemoSequence {
-6
View File
@@ -5,10 +5,4 @@
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
</ul>
{% if user and user.is_admin %}
<h2>Administration</h2>
<ul>
<li><a class="no-select" href="/admin/index.html">Admin Panel</a></li>
</ul>
{% endif %}
</aside>
-630
View File
@@ -1,630 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>API Reference - Snek Platform Documentation</title>
<meta name="description" content="Complete API reference for the Snek platform WebSocket RPC interface. Documentation for all available methods including authentication, channels, messages, and more." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section p {
margin-bottom: 1rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.section pre {
background: #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #7ef;
}
.method {
border-bottom: 1px solid #333;
padding: 1.25rem 0;
}
.method:last-child {
border-bottom: none;
}
.method-name {
font-family: 'Courier New', monospace;
font-size: 1.1rem;
color: #0fa;
font-weight: bold;
}
.method-sig {
font-family: 'Courier New', monospace;
color: #888;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.method-desc {
margin: 0.75rem 0;
color: #ccc;
}
.method-params {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.method-params strong {
color: #f05a28;
}
.method-returns {
font-size: 0.9rem;
color: #888;
}
.method-returns strong {
color: #7ef;
}
.auth-required {
display: inline-block;
background: #f05a28;
color: #fff;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 3px;
margin-left: 0.5rem;
vertical-align: middle;
}
.toc {
background: #1a1a1a;
padding: 1rem 1.5rem;
border-radius: 6px;
margin-bottom: 2rem;
}
.toc h3 {
color: #7ef;
margin-bottom: 0.75rem;
}
.toc ul {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
}
.toc a {
font-size: 0.9rem;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.toc ul {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html" class="active">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
<header class="container page-hero">
<h1>API Reference</h1>
<p>WebSocket RPC interface documentation</p>
</header>
<main class="container">
<nav class="toc">
<h3>Categories</h3>
<ul>
<li><a href="#protocol">Protocol</a></li>
<li><a href="#authentication">Authentication</a></li>
<li><a href="#user">User</a></li>
<li><a href="#channels">Channels</a></li>
<li><a href="#messages">Messages</a></li>
<li><a href="#presence">Presence</a></li>
<li><a href="#container">Container</a></li>
<li><a href="#database">Database</a></li>
<li><a href="#utility">Utility</a></li>
</ul>
</nav>
<section class="section" id="protocol">
<h2>Protocol</h2>
<p>Snek uses a JSON-RPC style protocol over WebSocket. Connect to <code>/rpc.ws</code> to establish a connection.</p>
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Request Format</h3>
<pre>{
"callId": "unique-id",
"method": "method_name",
"args": [arg1, arg2, ...]
}</pre>
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Response Format</h3>
<pre>{
"callId": "unique-id",
"success": true,
"data": { ... }
}</pre>
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Server Events</h3>
<p>The server pushes events without a callId:</p>
<pre>{
"channel_uid": "...",
"event": "new_message",
"data": { ... }
}</pre>
</section>
<section class="section" id="authentication">
<h2>Authentication</h2>
<div class="method">
<span class="method-name">login</span><span class="method-sig">(username, password)</span>
<p class="method-desc">Authenticate and establish a session. On success, the WebSocket connection is associated with the user and subscribed to all their channels.</p>
<p class="method-params"><strong>username</strong> (string) - The username</p>
<p class="method-params"><strong>password</strong> (string) - The password</p>
<p class="method-returns"><strong>Returns:</strong> User record (without password field) on success, error on failure</p>
</div>
<div class="method">
<span class="method-name">ping</span><span class="method-sig">()</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Keep the connection alive and update the user's last activity timestamp.</p>
<p class="method-returns"><strong>Returns:</strong> <code>{"pong": []}</code></p>
</div>
</section>
<section class="section" id="user">
<h2>User</h2>
<div class="method">
<span class="method-name">get_user</span><span class="method-sig">(user_uid=None)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get user information. If no user_uid is provided, returns the current user's data.</p>
<p class="method-params"><strong>user_uid</strong> (string, optional) - Target user's UID</p>
<p class="method-returns"><strong>Returns:</strong> User record (email hidden for other users)</p>
</div>
<div class="method">
<span class="method-name">search_user</span><span class="method-sig">(query)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Search for users by username.</p>
<p class="method-params"><strong>query</strong> (string) - Search query</p>
<p class="method-returns"><strong>Returns:</strong> Array of matching usernames</p>
</div>
</section>
<section class="section" id="channels">
<h2>Channels</h2>
<div class="method">
<span class="method-name">get_channels</span><span class="method-sig">()</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get all channels the current user is a member of.</p>
<p class="method-returns"><strong>Returns:</strong> Array of channel objects with name, uid, tag, new_count, is_moderator, is_read_only, color</p>
</div>
<div class="method">
<span class="method-name">create_channel</span><span class="method-sig">(name, description=None, is_private=False)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Create a new channel. The creating user becomes the moderator.</p>
<p class="method-params"><strong>name</strong> (string) - Channel name</p>
<p class="method-params"><strong>description</strong> (string, optional) - Channel description</p>
<p class="method-params"><strong>is_private</strong> (boolean, optional) - Whether the channel is private</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, uid: "...", name: "..."}</code></p>
</div>
<div class="method">
<span class="method-name">update_channel</span><span class="method-sig">(channel_uid, name, description=None, is_private=False)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Update channel settings. Only the channel creator or admin can update.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>name</strong> (string) - New channel name</p>
<p class="method-params"><strong>description</strong> (string, optional) - New description</p>
<p class="method-params"><strong>is_private</strong> (boolean, optional) - Privacy setting</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, name: "..."}</code></p>
</div>
<div class="method">
<span class="method-name">delete_channel</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Delete a channel (soft delete). Only the channel creator or admin can delete. Public channel cannot be deleted.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
</div>
<div class="method">
<span class="method-name">invite_user</span><span class="method-sig">(channel_uid, username)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Invite a user to a channel. Requires membership in the channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>username</strong> (string) - Username to invite</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, username: "..."}</code></p>
</div>
<div class="method">
<span class="method-name">leave_channel</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Leave a channel. Cannot leave the public channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
</div>
<div class="method">
<span class="method-name">clear_channel</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Clear all messages from a channel. Admin only.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
</section>
<section class="section" id="messages">
<h2>Messages</h2>
<div class="method">
<span class="method-name">send_message</span><span class="method-sig">(channel_uid, message, is_final=True)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Send a message to a channel. If is_final is false, the message can be updated (for typing indicators).</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Target channel UID</p>
<p class="method-params"><strong>message</strong> (string) - Message content</p>
<p class="method-params"><strong>is_final</strong> (boolean, optional) - Whether the message is final (default true)</p>
<p class="method-returns"><strong>Returns:</strong> Message UID on success</p>
</div>
<div class="method">
<span class="method-name">get_messages</span><span class="method-sig">(channel_uid, offset=0, timestamp=None)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get messages from a channel with pagination.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>offset</strong> (integer, optional) - Pagination offset</p>
<p class="method-params"><strong>timestamp</strong> (string, optional) - Filter by timestamp</p>
<p class="method-returns"><strong>Returns:</strong> Array of message objects</p>
</div>
<div class="method">
<span class="method-name">update_message_text</span><span class="method-sig">(message_uid, text)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Update a non-final message's text. Only the message author can update. Message must be less than 8 seconds old.</p>
<p class="method-params"><strong>message_uid</strong> (string) - Message UID</p>
<p class="method-params"><strong>text</strong> (string) - New text content</p>
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
</div>
<div class="method">
<span class="method-name">finalize_message</span><span class="method-sig">(message_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Mark a message as final, preventing further updates.</p>
<p class="method-params"><strong>message_uid</strong> (string) - Message UID</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">mark_as_read</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Mark all messages in a channel as read for the current user.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">get_first_unread_message_uid</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get the UID of the first unread message in a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Message UID or null</p>
</div>
</section>
<section class="section" id="presence">
<h2>Presence</h2>
<div class="method">
<span class="method-name">set_typing</span><span class="method-sig">(channel_uid, color=None)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Broadcast a typing indicator to channel members.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>color</strong> (string, optional) - Indicator color (defaults to user's color)</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">get_online_users</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get users currently online in a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Array of user objects sorted by nick</p>
</div>
<div class="method">
<span class="method-name">get_recent_users</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get users with recent activity in a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Array with uid, username, nick, last_ping</p>
</div>
<div class="method">
<span class="method-name">get_users</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get all members of a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Array with uid, username, nick, last_ping</p>
</div>
</section>
<section class="section" id="container">
<h2>Container</h2>
<p>Container methods provide access to channel-specific Docker containers (Ubuntu terminals).</p>
<div class="method">
<span class="method-name">get_container</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get container information for a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Container object with name, cpus, memory, image, status</p>
</div>
<div class="method">
<span class="method-name">start_container</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Start the container for a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">stop_container</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Stop the container for a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">get_container_status</span><span class="method-sig">(channel_uid)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get the current status of a channel's container.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-returns"><strong>Returns:</strong> Status string</p>
</div>
<div class="method">
<span class="method-name">write_container</span><span class="method-sig">(channel_uid, content, timeout=3)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Write to container stdin and capture output.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>content</strong> (string) - Content to write to stdin</p>
<p class="method-params"><strong>timeout</strong> (integer, optional) - Wait timeout in seconds (max 30)</p>
<p class="method-returns"><strong>Returns:</strong> Output string</p>
</div>
</section>
<section class="section" id="database">
<h2>Database</h2>
<p>Database methods provide direct access to user-scoped database operations.</p>
<div class="method">
<span class="method-name">db_insert</span><span class="method-sig">(table_name, record)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Insert a record into a user-scoped table.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Record to insert</p>
<p class="method-returns"><strong>Returns:</strong> Inserted record</p>
</div>
<div class="method">
<span class="method-name">db_update</span><span class="method-sig">(table_name, record)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Update a record in a user-scoped table.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Record with updates</p>
<p class="method-returns"><strong>Returns:</strong> Updated record</p>
</div>
<div class="method">
<span class="method-name">db_delete</span><span class="method-sig">(table_name, record)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Delete a record from a user-scoped table.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Record identifier</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
<div class="method">
<span class="method-name">db_get</span><span class="method-sig">(table_name, record)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Get a single record from a user-scoped table.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Query criteria</p>
<p class="method-returns"><strong>Returns:</strong> Record or null</p>
</div>
<div class="method">
<span class="method-name">db_find</span><span class="method-sig">(table_name, record)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Find records in a user-scoped table.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Query criteria</p>
<p class="method-returns"><strong>Returns:</strong> Array of records</p>
</div>
<div class="method">
<span class="method-name">db_upsert</span><span class="method-sig">(table_name, record, keys)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Insert or update a record based on key fields.</p>
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
<p class="method-params"><strong>record</strong> (object) - Record data</p>
<p class="method-params"><strong>keys</strong> (array) - Key fields for matching</p>
<p class="method-returns"><strong>Returns:</strong> Upserted record</p>
</div>
<div class="method">
<span class="method-name">db_query</span><span class="method-sig">(sql, args)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Execute a raw SQL query on user-scoped data.</p>
<p class="method-params"><strong>sql</strong> (string) - SQL query</p>
<p class="method-params"><strong>args</strong> (array) - Query parameters</p>
<p class="method-returns"><strong>Returns:</strong> Query results</p>
</div>
<div class="method">
<span class="method-name">query</span><span class="method-sig">(sql)</span>
<span class="auth-required">Auth Required</span>
<p class="method-desc">Execute a read-only SQL query. Forbidden keywords (DROP, ALTER, etc.) are blocked.</p>
<p class="method-params"><strong>sql</strong> (string) - SQL SELECT query</p>
<p class="method-returns"><strong>Returns:</strong> Array of records (sensitive fields filtered)</p>
</div>
</section>
<section class="section" id="utility">
<h2>Utility</h2>
<div class="method">
<span class="method-name">echo</span><span class="method-sig">(*args)</span>
<p class="method-desc">Echo back the provided arguments. Useful for testing.</p>
<p class="method-params"><strong>args</strong> - Any arguments</p>
<p class="method-returns"><strong>Returns:</strong> The same arguments</p>
</div>
<div class="method">
<span class="method-name">echo_raw</span><span class="method-sig">(obj)</span>
<p class="method-desc">Send a raw JSON object through the WebSocket. No response is sent back.</p>
<p class="method-params"><strong>obj</strong> (object) - Object to send</p>
<p class="method-returns"><strong>Returns:</strong> No response</p>
</div>
<div class="method">
<span class="method-name">stars_render</span><span class="method-sig">(channel_uid, message)</span>
<p class="method-desc">Broadcast a stars render event to all online users in a channel.</p>
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
<p class="method-params"><strong>message</strong> (string) - Message content</p>
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
</div>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
-367
View File
@@ -1,367 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Architecture - Snek Platform Documentation</title>
<meta name="description" content="Technical architecture documentation for the Snek platform. Learn about the layered architecture, backend services, frontend modules, and real-time communication systems." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section h3 {
color: #0fa;
margin: 1.5rem 0 0.75rem 0;
font-size: 1.1rem;
}
.section p, .section ul {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.section li {
margin-bottom: 0.5rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.section pre {
background: #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #7ef;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
.arch-diagram {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 1.5rem;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.4;
color: #ccc;
overflow-x: auto;
}
.arch-diagram .layer {
border: 1px dashed #444;
padding: 0.75rem;
margin: 0.5rem 0;
border-radius: 4px;
}
.arch-diagram .layer-name {
color: #7ef;
font-weight: bold;
margin-bottom: 0.5rem;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html" class="active">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
<header class="container page-hero">
<h1>Architecture</h1>
<p>Technical architecture and system design of the Snek platform</p>
</header>
<main class="container">
<section class="section">
<h2>System Overview</h2>
<p>Snek follows a layered architecture pattern with clear separation of concerns. The system is built on Python's aiohttp framework for asynchronous HTTP and WebSocket handling.</p>
<div class="arch-diagram">
<div class="layer">
<div class="layer-name">Frontend Layer</div>
Vanilla JavaScript ES6 Modules | Custom Elements | WebSocket Client
</div>
<div class="layer">
<div class="layer-name">View Layer</div>
HTTP Handlers | WebSocket RPC | Template Rendering | Form Processing
</div>
<div class="layer">
<div class="layer-name">Service Layer</div>
Business Logic | Caching | Event Broadcasting | Authentication
</div>
<div class="layer">
<div class="layer-name">Mapper Layer</div>
Data Access | CRUD Operations | Soft Delete | Query Building
</div>
<div class="layer">
<div class="layer-name">Database Layer</div>
SQLite | Dataset ORM | Schema Management
</div>
</div>
</section>
<section class="section">
<h2>Backend Architecture</h2>
<h3>aiohttp Framework</h3>
<p>The backend is built on aiohttp, a high-performance asynchronous HTTP client/server framework. Key features:</p>
<ul>
<li>Fully asynchronous request handling with async/await</li>
<li>Native WebSocket support for real-time communication</li>
<li>Session management via aiohttp-session</li>
<li>Static file serving and template rendering</li>
</ul>
<h3>Service Layer Pattern</h3>
<p>Services encapsulate business logic and are accessed via a singleton registry:</p>
<pre>user = await app.services.user.get(uid=user_uid)
channel = await app.services.channel.create(label="#general")</pre>
<p>Available services include: user, channel, channel_member, channel_message, chat, socket, container, db, and more.</p>
<h3>Mapper Pattern</h3>
<p>Mappers provide data access abstraction with consistent CRUD operations:</p>
<pre>await mapper.get(uid=uid)
await mapper.find(field=value)
await mapper.save(model)
async for record in mapper.query(sql, params): ...</pre>
<p>All mappers support soft delete via the <code>deleted_at</code> field.</p>
<h3>Caching Strategy</h3>
<p>The service layer implements UID-based caching for frequently accessed data. Cache invalidation occurs on model updates.</p>
</section>
<section class="section">
<h2>Frontend Architecture</h2>
<h3>Vanilla JavaScript Philosophy</h3>
<p>The frontend uses pure ES6 JavaScript modules without any frameworks. This ensures:</p>
<ul>
<li>Minimal bundle size and fast load times</li>
<li>No framework upgrade cycles or breaking changes</li>
<li>Direct DOM manipulation for maximum performance</li>
<li>Easy debugging without transpilation</li>
</ul>
<h3>Module Structure</h3>
<pre>src/snek/static/
app.js # Main Application class (singleton)
socket.js # WebSocket RPC client
chat-window.js # Chat interface component
message-list.js # Message rendering
chat-input.js # Input handling
...</pre>
<h3>Custom Elements</h3>
<p>UI components are implemented as Custom Elements:</p>
<pre>&lt;chat-window&gt;&lt;/chat-window&gt;
&lt;message-list&gt;&lt;/message-list&gt;
&lt;channel-menu&gt;&lt;/channel-menu&gt;
&lt;nav-menu&gt;&lt;/nav-menu&gt;</pre>
<p>Each component has its own CSS file (no Shadow DOM per project guidelines).</p>
</section>
<section class="section">
<h2>Database Design</h2>
<h3>Dataset ORM</h3>
<p>Snek uses Dataset, a simple database abstraction layer built on SQLAlchemy. It provides:</p>
<ul>
<li>Automatic table creation and schema inference</li>
<li>Dictionary-based record access</li>
<li>Transaction support</li>
<li>Raw SQL query execution when needed</li>
</ul>
<h3>Core Tables</h3>
<pre>user # User accounts
channel # Chat channels
channel_member # Channel membership
channel_message# Chat messages
session # User sessions
...</pre>
<h3>Soft Delete Pattern</h3>
<p>Records are never physically deleted. Instead, the <code>deleted_at</code> timestamp is set. All queries filter by <code>deleted_at IS NULL</code> by default.</p>
</section>
<section class="section">
<h2>Real-time Communication</h2>
<h3>WebSocket RPC System</h3>
<p>Real-time features use a JSON-RPC style protocol over WebSocket:</p>
<pre>{
"callId": "1",
"method": "send_message",
"args": ["channel_uid", "Hello world", true]
}</pre>
<p>The server responds with:</p>
<pre>{
"callId": "1",
"success": true,
"data": "message_uid"
}</pre>
<h3>Event Broadcasting</h3>
<p>Server-initiated events are broadcast to channel subscribers:</p>
<pre>{
"channel_uid": "...",
"event": "new_message",
"data": { ... }
}</pre>
<h3>Channel Subscription Model</h3>
<p>Users automatically subscribe to all their channels on WebSocket connection. The socket service manages subscriptions and delivers messages to the appropriate clients.</p>
</section>
<section class="section">
<h2>Directory Structure</h2>
<pre>src/snek/
__main__.py # CLI entry point
app.py # Application setup and routing
view/ # HTTP/WebSocket handlers
index.py # Landing page
rpc.py # WebSocket RPC
settings/ # Settings views
service/ # Business logic layer
mapper/ # Data persistence
model/ # Data models
form/ # Form definitions
system/ # Core infrastructure
view.py # BaseView class
service.py # BaseService class
mapper.py # BaseMapper class
model.py # BaseModel class
static/ # Frontend JavaScript and CSS
templates/ # Jinja2 templates</pre>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
-655
View File
@@ -1,655 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Bot Development - Snek Platform Documentation</title>
<meta name="description" content="Guide to developing bots for the Snek platform. Complete examples in Python and JavaScript for building automated chat bots." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section h3 {
color: #0fa;
margin: 1.5rem 0 0.75rem 0;
font-size: 1.1rem;
}
.section p, .section ul {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.section li {
margin-bottom: 0.5rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.section pre {
background: #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: #ccc;
line-height: 1.4;
}
.section pre .kw { color: #f05a28; }
.section pre .fn { color: #7ef; }
.section pre .str { color: #0fa; }
.section pre .cm { color: #666; }
.code-label {
display: inline-block;
background: #f05a28;
color: #fff;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 3px 3px 0 0;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.section pre { font-size: 0.7rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html" class="active">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
<header class="container page-hero">
<h1>Bot Development</h1>
<p>Build automated bots for the Snek platform</p>
</header>
<main class="container">
<section class="section">
<h2>Introduction</h2>
<p>Snek bots are automated clients that connect via WebSocket and interact with the platform using the RPC API. Bots can:</p>
<ul>
<li>Monitor channels and respond to messages</li>
<li>Send automated notifications</li>
<li>Integrate with external services (CI/CD, monitoring, etc.)</li>
<li>Provide AI-powered assistance</li>
<li>Automate moderation tasks</li>
</ul>
<p>Bots authenticate like regular users. Create a dedicated user account for your bot.</p>
</section>
<section class="section">
<h2>Protocol Overview</h2>
<p>Bots communicate over WebSocket using a JSON-RPC style protocol:</p>
<h3>Connection</h3>
<p>Connect to <code>wss://your-server/rpc.ws</code> (or <code>ws://</code> for local development).</p>
<h3>Request Format</h3>
<pre>{
"callId": "1",
"method": "login",
"args": ["bot_username", "bot_password"]
}</pre>
<h3>Response Format</h3>
<pre>{
"callId": "1",
"success": true,
"data": { "uid": "...", "username": "bot_username", ... }
}</pre>
<h3>Server Events</h3>
<p>After login, the server pushes events for new messages:</p>
<pre>{
"channel_uid": "abc123",
"event": "new_message",
"data": {
"uid": "msg_uid",
"message": "Hello bot!",
"user_uid": "sender_uid",
"username": "sender_name"
}
}</pre>
</section>
<section class="section">
<h2>Python Bot Example</h2>
<p>A complete, working Python bot using aiohttp:</p>
<span class="code-label">Python</span>
<pre># retoor &lt;retoor@molodetz.nl&gt;
import asyncio
import json
import aiohttp
class SnekBot:
def __init__(self, base_url, username, password):
self.base_url = base_url
self.username = username
self.password = password
self.ws = None
self.session = None
self.call_id = 0
self.pending = {}
self.running = True
async def connect(self):
self.session = aiohttp.ClientSession()
ws_url = self.base_url.replace("https", "wss").replace("http", "ws")
ws_url = f"{ws_url}/rpc.ws"
self.ws = await self.session.ws_connect(ws_url, heartbeat=30)
asyncio.create_task(self._message_loop())
user = await self.login()
print(f"Logged in as {user.get('username')}")
return user
async def _message_loop(self):
try:
async for msg in self.ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
call_id = data.get("callId")
if call_id and call_id in self.pending:
self.pending[call_id].set_result(data)
elif data.get("event") == "new_message":
await self.on_message(data.get("data", {}))
elif data.get("event"):
await self.on_event(data)
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
except Exception as e:
print(f"Message loop error: {e}")
finally:
self.running = False
async def call(self, method, *args):
self.call_id += 1
call_id = str(self.call_id)
future = asyncio.get_event_loop().create_future()
self.pending[call_id] = future
await self.ws.send_json({
"callId": call_id,
"method": method,
"args": list(args)
})
try:
result = await asyncio.wait_for(future, timeout=30)
return result.get("data")
finally:
self.pending.pop(call_id, None)
async def login(self):
return await self.call("login", self.username, self.password)
async def send_message(self, channel_uid, message):
return await self.call("send_message", channel_uid, message, True)
async def get_channels(self):
return await self.call("get_channels")
async def on_message(self, data):
message = data.get("message", "")
channel_uid = data.get("channel_uid")
username = data.get("username", "")
if username == self.username:
return
if message.lower().startswith("!ping"):
await self.send_message(channel_uid, "Pong!")
elif message.lower().startswith("!help"):
help_text = "Available commands: !ping, !help, !time"
await self.send_message(channel_uid, help_text)
elif message.lower().startswith("!time"):
import datetime
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
await self.send_message(channel_uid, f"Current time: {now}")
async def on_event(self, data):
event = data.get("event")
print(f"Event: {event}")
async def close(self):
self.running = False
if self.ws:
await self.ws.close()
if self.session:
await self.session.close()
async def main():
bot = SnekBot(
base_url="https://snek.community",
username="my_bot",
password="my_bot_password"
)
try:
await bot.connect()
channels = await bot.get_channels()
print(f"Member of {len(channels)} channels")
while bot.running:
await asyncio.sleep(1)
except KeyboardInterrupt:
print("Shutting down...")
finally:
await bot.close()
if __name__ == "__main__":
asyncio.run(main())</pre>
</section>
<section class="section">
<h2>JavaScript Bot Example</h2>
<p>A complete Node.js bot using the ws package:</p>
<span class="code-label">JavaScript (Node.js)</span>
<pre>// retoor &lt;retoor@molodetz.nl&gt;
const WebSocket = require("ws")
class SnekBot {
constructor(baseUrl, username, password) {
this.baseUrl = baseUrl
this.username = username
this.password = password
this.ws = null
this.callId = 0
this.pending = new Map()
this.running = true
}
connect() {
return new Promise((resolve, reject) => {
const wsUrl = this.baseUrl
.replace("https", "wss")
.replace("http", "ws") + "/rpc.ws"
this.ws = new WebSocket(wsUrl)
this.ws.on("open", async () => {
try {
const user = await this.login()
console.log(`Logged in as ${user.username}`)
resolve(user)
} catch (e) {
reject(e)
}
})
this.ws.on("message", (data) => {
const parsed = JSON.parse(data.toString())
const callId = parsed.callId
if (callId && this.pending.has(callId)) {
this.pending.get(callId).resolve(parsed)
this.pending.delete(callId)
} else if (parsed.event === "new_message") {
this.onMessage(parsed.data || {})
} else if (parsed.event) {
this.onEvent(parsed)
}
})
this.ws.on("close", () => {
this.running = false
console.log("Connection closed")
})
this.ws.on("error", (err) => {
console.error("WebSocket error:", err.message)
reject(err)
})
})
}
call(method, ...args) {
return new Promise((resolve, reject) => {
this.callId++
const callId = String(this.callId)
const timeout = setTimeout(() => {
this.pending.delete(callId)
reject(new Error("Request timeout"))
}, 30000)
this.pending.set(callId, {
resolve: (data) => {
clearTimeout(timeout)
resolve(data.data)
},
reject
})
this.ws.send(JSON.stringify({
callId,
method,
args
}))
})
}
login() {
return this.call("login", this.username, this.password)
}
sendMessage(channelUid, message) {
return this.call("send_message", channelUid, message, true)
}
getChannels() {
return this.call("get_channels")
}
async onMessage(data) {
const message = data.message || ""
const channelUid = data.channel_uid
const username = data.username || ""
if (username === this.username) {
return
}
if (message.toLowerCase().startsWith("!ping")) {
await this.sendMessage(channelUid, "Pong!")
} else if (message.toLowerCase().startsWith("!help")) {
await this.sendMessage(channelUid, "Commands: !ping, !help, !time")
} else if (message.toLowerCase().startsWith("!time")) {
const now = new Date().toISOString()
await this.sendMessage(channelUid, `Current time: ${now}`)
}
}
onEvent(data) {
console.log(`Event: ${data.event}`)
}
close() {
this.running = false
if (this.ws) {
this.ws.close()
}
}
}
async function main() {
const bot = new SnekBot(
"https://snek.community",
"my_bot",
"my_bot_password"
)
try {
await bot.connect()
const channels = await bot.getChannels()
console.log(`Member of ${channels.length} channels`)
process.on("SIGINT", () => {
console.log("Shutting down...")
bot.close()
process.exit(0)
})
} catch (e) {
console.error("Failed to connect:", e.message)
process.exit(1)
}
}
main()</pre>
</section>
<section class="section">
<h2>Event Handling</h2>
<p>Bots receive various events after authentication:</p>
<h3>new_message</h3>
<p>Received when a message is posted to a channel the bot is a member of.</p>
<pre>{
"channel_uid": "...",
"event": "new_message",
"data": {
"uid": "message_uid",
"message": "Message content",
"user_uid": "sender_uid",
"username": "sender_username",
"nick": "Sender Nick",
"created_at": "2025-01-01T12:00:00"
}
}</pre>
<h3>set_typing</h3>
<p>Received when a user starts typing.</p>
<pre>{
"channel_uid": "...",
"event": "set_typing",
"data": {
"user_uid": "...",
"username": "...",
"nick": "...",
"color": "#7ef"
}
}</pre>
<h3>update_message_text</h3>
<p>Received when a message is edited (non-final messages only).</p>
<pre>{
"channel_uid": "...",
"event": "update_message_text",
"data": {
"message_uid": "...",
"text": "Updated message content"
}
}</pre>
<h3>user_presence</h3>
<p>Received when a user's online status changes.</p>
</section>
<section class="section">
<h2>Best Practices</h2>
<h3>Rate Limiting</h3>
<p>Implement rate limiting to avoid overwhelming the server:</p>
<ul>
<li>Limit outgoing messages to 1-2 per second per channel</li>
<li>Batch multiple operations where possible</li>
<li>Add delays between bulk operations</li>
</ul>
<h3>Error Handling</h3>
<p>Handle errors gracefully:</p>
<ul>
<li>Catch and log all exceptions</li>
<li>Handle authentication failures specifically</li>
<li>Validate input before sending to the API</li>
</ul>
<h3>Reconnection Strategy</h3>
<p>Implement automatic reconnection:</p>
<ul>
<li>Detect disconnection via heartbeat timeout</li>
<li>Use exponential backoff (1s, 2s, 4s, 8s, max 60s)</li>
<li>Re-authenticate after reconnection</li>
<li>Restore subscriptions after login</li>
</ul>
<h3>Resource Cleanup</h3>
<p>Clean up resources on shutdown:</p>
<ul>
<li>Close WebSocket connections properly</li>
<li>Cancel pending timers and promises</li>
<li>Handle SIGINT/SIGTERM signals</li>
</ul>
<h3>Security</h3>
<ul>
<li>Store credentials in environment variables, not code</li>
<li>Use a dedicated bot account with minimal permissions</li>
<li>Validate and sanitize user input before processing</li>
<li>Log actions for audit purposes</li>
</ul>
</section>
<section class="section">
<h2>Advanced Topics</h2>
<h3>AI Integration</h3>
<p>Integrate with AI services by calling external APIs when messages match certain patterns:</p>
<pre>async def on_message(self, data):
message = data.get("message", "")
if message.startswith("!ask "):
query = message[5:]
response = await self.call_ai_api(query)
await self.send_message(data["channel_uid"], response)</pre>
<h3>Scheduled Tasks</h3>
<p>Use asyncio tasks for periodic operations:</p>
<pre>async def daily_report(self):
while self.running:
await asyncio.sleep(86400) # 24 hours
for channel in await self.get_channels():
await self.send_message(channel["uid"], "Daily report...")</pre>
<h3>Multiple Bots</h3>
<p>Run multiple bot instances for different purposes. Each bot needs its own user account and WebSocket connection.</p>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
-444
View File
@@ -1,444 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Contribute - Snek Platform Documentation</title>
<meta name="description" content="Guide to contributing to the Snek platform. Learn the project structure, code standards, and how to add new features." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section h3 {
color: #0fa;
margin: 1.5rem 0 0.75rem 0;
font-size: 1.1rem;
}
.section p, .section ul, .section ol {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.section ol {
list-style: decimal inside;
}
.section li {
margin-bottom: 0.5rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.section pre {
background: #222;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #7ef;
line-height: 1.4;
}
.dir-tree {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 1rem 1.5rem;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #ccc;
}
.dir-tree .dir { color: #7ef; }
.dir-tree .file { color: #aaa; }
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html" class="active">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
<header class="container page-hero">
<h1>Contribute</h1>
<p>How to contribute to the Snek platform</p>
</header>
<main class="container">
<section class="section">
<h2>Getting Started</h2>
<h3>Clone the Repository</h3>
<pre>git clone https://retoor.molodetz.nl/retoor/snek.git
cd snek</pre>
<h3>Setup Development Environment</h3>
<pre>make install</pre>
<p>This creates a virtual environment and installs all dependencies.</p>
<h3>Run Locally</h3>
<pre>make run</pre>
<p>The server starts at <code>http://localhost:8080</code> by default.</p>
<h3>Run Tests</h3>
<pre>pytest tests/</pre>
</section>
<section class="section">
<h2>Project Structure</h2>
<div class="dir-tree">
<span class="dir">src/snek/</span><br>
&nbsp;&nbsp;<span class="file">__main__.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;CLI entry point<br>
&nbsp;&nbsp;<span class="file">app.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Application setup, routing<br>
&nbsp;&nbsp;<span class="dir">view/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;HTTP/WebSocket handlers<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">index.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Landing page<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">rpc.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;WebSocket RPC<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">settings/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Settings views<br>
&nbsp;&nbsp;<span class="dir">service/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Business logic layer<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">user.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;User service<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">channel.py</span> &nbsp;&nbsp;&nbsp;&nbsp;Channel service<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">chat.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Chat service<br>
&nbsp;&nbsp;<span class="dir">mapper/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Data persistence<br>
&nbsp;&nbsp;<span class="dir">model/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Data models<br>
&nbsp;&nbsp;<span class="dir">form/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Form definitions<br>
&nbsp;&nbsp;<span class="dir">system/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Core infrastructure<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">view.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;BaseView class<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">service.py</span> &nbsp;&nbsp;&nbsp;&nbsp;BaseService class<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">mapper.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;BaseMapper class<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">model.py</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;BaseModel class<br>
&nbsp;&nbsp;<span class="dir">static/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Frontend JS and CSS<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">app.js</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Application singleton<br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">socket.js</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;WebSocket client<br>
&nbsp;&nbsp;<span class="dir">templates/</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Jinja2 templates<br>
</div>
</section>
<section class="section">
<h2>Code Standards</h2>
<h3>Author Attribution</h3>
<p>Every file must include the author tag at the top:</p>
<pre># retoor &lt;retoor@molodetz.nl&gt;</pre>
<p>For JavaScript:</p>
<pre>// retoor &lt;retoor@molodetz.nl&gt;</pre>
<h3>No Comments</h3>
<p>Code should be self-documenting. If you need to explain something, refactor the code to be clearer. Comments indicate code that needs improvement.</p>
<h3>No JavaScript Frameworks</h3>
<p>The frontend uses vanilla ES6 JavaScript only. No React, Vue, Angular, or similar frameworks. Use Custom Elements for component encapsulation.</p>
<h3>Explicit Over Implicit</h3>
<p>Prefer explicit code paths. No magic imports, no hidden side effects, no implicit type conversions.</p>
<h3>Variable Naming</h3>
<p>Use clear, descriptive names. Avoid abbreviations except for common conventions (uid, url, etc.). No suffixes like <code>_super</code> or <code>_refactored</code>.</p>
<h3>Module Organization</h3>
<p>JavaScript: one class per file. Python: related classes can share a file, but keep files focused. Use directories to group related modules.</p>
</section>
<section class="section">
<h2>Adding a New View</h2>
<p>Views handle HTTP requests and render responses.</p>
<h3>1. Create the View Class</h3>
<pre># retoor &lt;retoor@molodetz.nl&gt;
from snek.system.view import BaseView
class MyFeatureView(BaseView):
login_required = True
async def get(self):
data = await self.services.my_service.get_data()
return await self.render_template("my_feature.html", {"data": data})</pre>
<h3>2. Create the Template</h3>
<p>Add a template at <code>templates/my_feature.html</code>:</p>
<pre>{% raw %}{% extends "base.html" %}
{% block title %}My Feature{% endblock %}
{% block main %}
&lt;div class="container"&gt;
&lt;h1&gt;My Feature&lt;/h1&gt;
&lt;p&gt;{{ data }}&lt;/p&gt;
&lt;/div&gt;
{% endblock %}{% endraw %}</pre>
<h3>3. Register the Route</h3>
<p>In <code>app.py</code> setup_router method:</p>
<pre>from snek.view.my_feature import MyFeatureView
self.router.add_view("/my-feature.html", MyFeatureView)</pre>
</section>
<section class="section">
<h2>Adding a New Service</h2>
<p>Services contain business logic and are accessed via <code>app.services.name</code>.</p>
<h3>1. Create the Service Class</h3>
<pre># retoor &lt;retoor@molodetz.nl&gt;
from snek.system.service import BaseService
class MyService(BaseService):
mapper_class = None
async def process_data(self, data):
return {"processed": data}</pre>
<h3>2. Register the Service</h3>
<p>Services are auto-discovered from the <code>service/</code> directory. The service name is derived from the filename (e.g., <code>my_service.py</code> becomes <code>app.services.my_service</code>).</p>
</section>
<section class="section">
<h2>Adding RPC Methods</h2>
<p>RPC methods are defined in the <code>RPCApi</code> class in <code>view/rpc.py</code>.</p>
<h3>Add the Method</h3>
<pre>async def my_method(self, param1, param2=None):
self._require_login()
self._require_services()
if not param1 or not isinstance(param1, str):
raise ValueError("Invalid param1")
result = await self.services.my_service.process(param1, param2)
return result</pre>
<h3>Method Guidelines</h3>
<ul>
<li>Use <code>self._require_login()</code> for authenticated methods</li>
<li>Use <code>self._require_services()</code> to ensure services are available</li>
<li>Validate all input parameters</li>
<li>Return serializable data (dicts, lists, primitives)</li>
<li>Raise <code>ValueError</code> for invalid input</li>
<li>Raise <code>PermissionError</code> for authorization failures</li>
</ul>
</section>
<section class="section">
<h2>Adding Frontend Components</h2>
<p>Frontend components use Custom Elements.</p>
<h3>1. Create the Component</h3>
<pre>// retoor &lt;retoor@molodetz.nl&gt;
class MyComponent extends HTMLElement {
constructor() {
super()
this.data = null
}
connectedCallback() {
this.render()
}
render() {
this.innerHTML = `
&lt;div class="my-component"&gt;
&lt;h2&gt;My Component&lt;/h2&gt;
&lt;/div&gt;
`
}
}
customElements.define("my-component", MyComponent)
export { MyComponent }</pre>
<h3>2. Create the CSS</h3>
<p>Add styles in a separate CSS file (no Shadow DOM):</p>
<pre>.my-component {
background: #1a1a1a;
padding: 1rem;
border-radius: 6px;
}
.my-component h2 {
color: #7ef;
}</pre>
<h3>3. Import in the Template</h3>
<pre>&lt;script type="module" src="/my-component.js"&gt;&lt;/script&gt;
&lt;link rel="stylesheet" href="/my-component.css"&gt;</pre>
</section>
<section class="section">
<h2>Testing</h2>
<h3>Running Tests</h3>
<pre>pytest tests/ # All tests
pytest tests/test_user.py # Single file
pytest tests/test_user.py::test_login # Single test
pytest -m unit # Unit tests only
pytest -m integration # Integration tests</pre>
<h3>Writing Tests</h3>
<pre># retoor &lt;retoor@molodetz.nl&gt;
import pytest
@pytest.mark.unit
async def test_my_feature():
result = await my_function()
assert result is not None</pre>
</section>
<section class="section">
<h2>Pull Request Guidelines</h2>
<ol>
<li>Create a feature branch from <code>main</code></li>
<li>Make focused, atomic commits</li>
<li>Write clear commit messages</li>
<li>Ensure all tests pass</li>
<li>Follow the code standards</li>
<li>Update documentation if needed</li>
<li>Submit PR with description of changes</li>
</ol>
<h3>Commit Message Format</h3>
<pre>feat: add user profile editing
fix: resolve channel subscription issue
docs: update API documentation
refactor: simplify message handling</pre>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
-320
View File
@@ -1,320 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Design Decisions - Snek Platform Documentation</title>
<meta name="description" content="Design philosophy and technical decisions behind the Snek platform. Learn why we chose specific technologies and architectural patterns." />
<link rel="stylesheet" href="/sandbox.css" />
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height: 100%; }
body {
font-family: 'Segoe UI',sans-serif;
background: #111;
color: #eee;
line-height:1.5;
min-height: 100vh;
position: relative;
overflow-x: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
}
a { color: #7ef; text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
.page-hero {
text-align: center;
padding: 3rem 0 2rem 0;
}
.page-hero h1 {
font-size: 2.5rem;
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: .5rem;
}
.page-hero p {
color: #aaa;
font-size: 1.1rem;
}
.section {
background: #181818;
border-radius: 6px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.section h2 {
color: #7ef;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.section h3 {
color: #0fa;
margin: 1.5rem 0 0.75rem 0;
font-size: 1.1rem;
}
.section p, .section ul {
margin-bottom: 1rem;
}
.section ul {
list-style: disc inside;
}
.section li {
margin-bottom: 0.5rem;
}
.section code {
background: #222;
padding: 2px 6px;
border-radius: 3px;
color: #7ef;
font-family: 'Courier New', monospace;
}
.decision-box {
background: #1a1a1a;
border-left: 3px solid #f05a28;
padding: 1rem 1.25rem;
margin: 1rem 0;
border-radius: 0 4px 4px 0;
}
.decision-box strong {
color: #f05a28;
}
.topnav {
width: 100%;
background: #181818;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
padding: 0.6rem 0;
}
.topnav .nav-container {
width: 90%;
max-width: 960px;
margin: auto;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
}
.topnav a {
color: #7ef;
font-weight: 600;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: background 0.14s;
text-decoration: none;
display: inline-block;
}
.topnav a:hover, .topnav a.active {
background: #222b;
text-decoration: underline;
}
.topnav .home-link {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 1rem;
font-weight: bold;
margin-right: 1rem;
}
.topnav .home-link img {
width: 28px;
height: 28px;
border-radius: 5px;
background: #222;
}
footer {
width: 100%;
background: #181818;
color: #aaa;
text-align: center;
padding: 1.4rem 0;
font-size: 1rem;
margin-top: auto;
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
}
@media (max-width: 600px) {
.page-hero h1 { font-size: 2rem; }
.section { padding: 1rem; }
.topnav .nav-container {
flex-direction: column;
align-items: flex-start;
}
.topnav a {
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-container">
<a class="home-link" href="/">
<img src="/image/snek_logo_256x256.png" alt="Snek" />
Snek
</a>
<a href="/docs.html">Docs</a>
<a href="/architecture.html">Architecture</a>
<a href="/api.html">API</a>
<a href="/bots.html">Bots</a>
<a href="/contribute.html">Contribute</a>
<a href="/about.html">About</a>
</div>
</nav>
<header class="container page-hero">
<h1>Design Decisions</h1>
<p>Philosophy and technical choices that shape the Snek platform</p>
</header>
<main class="container">
<section class="section">
<h2>Core Philosophy</h2>
<p>Snek is built on a set of guiding principles that inform every technical and design decision:</p>
<ul>
<li><strong>Privacy First:</strong> No tracking, no analytics, no email required. User data belongs to users.</li>
<li><strong>Performance Focus:</strong> Fast is a feature. Minimal dependencies, optimized code paths.</li>
<li><strong>Simplicity Over Complexity:</strong> Prefer straightforward solutions. Complexity must justify itself.</li>
<li><strong>Self-Hosting First:</strong> The platform should be trivial to deploy and maintain independently.</li>
<li><strong>No Framework Lock-in:</strong> Avoid dependencies that impose architectural constraints.</li>
</ul>
</section>
<section class="section">
<h2>Technical Decisions</h2>
<h3>Why aiohttp Over Flask/Django</h3>
<div class="decision-box">
<strong>Decision:</strong> Use aiohttp as the web framework.
</div>
<p>Rationale:</p>
<ul>
<li>Native async/await support without ASGI adapters</li>
<li>Built-in WebSocket support with no additional libraries</li>
<li>Lower memory footprint than Django</li>
<li>No ORM assumptions or admin interface overhead</li>
<li>Direct control over request/response lifecycle</li>
</ul>
<h3>Why Dataset ORM Over SQLAlchemy</h3>
<div class="decision-box">
<strong>Decision:</strong> Use Dataset for database access.
</div>
<p>Rationale:</p>
<ul>
<li>Zero configuration required for basic operations</li>
<li>Dictionary-based interface matches JSON API patterns</li>
<li>Automatic table creation simplifies development</li>
<li>Raw SQL available when needed for complex queries</li>
<li>Significantly less boilerplate than SQLAlchemy models</li>
</ul>
<h3>Why Vanilla JavaScript Over React/Vue</h3>
<div class="decision-box">
<strong>Decision:</strong> Use vanilla ES6 JavaScript with no frameworks.
</div>
<p>Rationale:</p>
<ul>
<li>No build step required - direct browser execution</li>
<li>No framework version upgrades or breaking changes</li>
<li>Smaller payload - no framework runtime overhead</li>
<li>Easier debugging - no virtual DOM abstraction</li>
<li>Custom Elements provide component encapsulation</li>
<li>Direct DOM access for performance-critical paths</li>
</ul>
<h3>Why SQLite as Default Database</h3>
<div class="decision-box">
<strong>Decision:</strong> Use SQLite as the default database.
</div>
<p>Rationale:</p>
<ul>
<li>Zero configuration deployment - single file database</li>
<li>No separate database server process required</li>
<li>Excellent read performance for chat workloads</li>
<li>Easy backup - copy a single file</li>
<li>PostgreSQL available for high-write scenarios</li>
</ul>
</section>
<section class="section">
<h2>Security Decisions</h2>
<h3>No IP Logging</h3>
<div class="decision-box">
<strong>Decision:</strong> Do not log IP addresses or user activity.
</div>
<p>Snek does not record IP addresses, request logs, or user behavior patterns. This is a fundamental privacy commitment, not a configuration option.</p>
<h3>No Email Required</h3>
<div class="decision-box">
<strong>Decision:</strong> Registration requires only username and password.
</div>
<p>Email is optional. Users can register and participate without providing any personally identifiable information. Password recovery relies on administrator assistance.</p>
<h3>Session Handling</h3>
<p>Sessions use secure, HTTP-only cookies with server-side storage. Session data is encrypted and expires after configurable inactivity periods.</p>
<h3>Password Storage</h3>
<p>Passwords are hashed using SHA-256 with a configurable salt. The plain password is never stored or logged.</p>
</section>
<section class="section">
<h2>UI/UX Decisions</h2>
<h3>Dark Theme Default</h3>
<div class="decision-box">
<strong>Decision:</strong> Use dark theme as the default and primary design.
</div>
<p>The dark theme reduces eye strain for extended use, is preferred by developers, and provides better contrast for code display.</p>
<h3>Card-Based Layouts</h3>
<p>Content is organized in cards with consistent padding, border-radius, and shadow. Cards provide visual grouping and work well across screen sizes.</p>
<h3>Minimal Animation</h3>
<p>Animations are limited to subtle transitions (hover states, menu opens). No gratuitous motion that could distract or slow down interaction.</p>
<h3>Typography</h3>
<ul>
<li>Primary font: Segoe UI (system font stack fallback)</li>
<li>Code font: Courier New (monospace)</li>
<li>Base size: 16px with 1.5 line height</li>
<li>Color palette: #eee (text), #7ef (links/accents), #0fa (secondary)</li>
</ul>
<h3>No Component Shadow DOM</h3>
<div class="decision-box">
<strong>Decision:</strong> Custom Elements use light DOM with separate CSS files.
</div>
<p>Each component has its own CSS file that integrates with the global stylesheet. This allows consistent theming and easier debugging compared to Shadow DOM isolation.</p>
</section>
<section class="section">
<h2>Code Standards</h2>
<h3>No Comments</h3>
<p>Code should be self-documenting through clear naming and structure. Comments indicate code that needs refactoring.</p>
<h3>Author Attribution</h3>
<p>Every file includes the author tag at the top:</p>
<code># retoor &lt;retoor@molodetz.nl&gt;</code>
<h3>Modular Organization</h3>
<p>Code is organized into small, focused modules. One class per file for JavaScript. Related functionality grouped in directories.</p>
<h3>Explicit Over Implicit</h3>
<p>Prefer explicit code paths over magic behavior. No auto-imports, no implicit conversions, no hidden side effects.</p>
</section>
</main>
<footer>
<p>&copy; 2025 Snek Platform - retoor &lt;retoor@molodetz.nl&gt;</p>
</footer>
{% include "sandbox.html" %}
</body>
</html>
-34
View File
@@ -1,34 +0,0 @@
# retoor <retoor@molodetz.nl>
from snek.view.admin.index import AdminIndexView
from snek.view.admin.users import AdminUsersIndexView, AdminUserEditView, AdminUserBanView
from snek.view.admin.channels import AdminChannelsIndexView, AdminChannelMembersView, AdminChannelEditView, AdminChannelClearView, AdminChannelDeleteView
from snek.view.admin.messages import AdminMessagesIndexView
from snek.view.admin.forums import AdminForumsIndexView, AdminThreadsView, AdminPostsView
from snek.view.admin.files import AdminFilesIndexView, AdminDriveItemsView, AdminRepositoriesView
from snek.view.admin.notifications import AdminNotificationsIndexView, AdminNotificationCreateView
from snek.view.admin.system import AdminSystemIndexView, AdminKVView, AdminPushView
__all__ = [
"AdminIndexView",
"AdminUsersIndexView",
"AdminUserEditView",
"AdminUserBanView",
"AdminChannelsIndexView",
"AdminChannelMembersView",
"AdminChannelEditView",
"AdminChannelClearView",
"AdminChannelDeleteView",
"AdminMessagesIndexView",
"AdminForumsIndexView",
"AdminThreadsView",
"AdminPostsView",
"AdminFilesIndexView",
"AdminDriveItemsView",
"AdminRepositoriesView",
"AdminNotificationsIndexView",
"AdminNotificationCreateView",
"AdminSystemIndexView",
"AdminKVView",
"AdminPushView",
]
-24
View File
@@ -1,24 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.system.view import BaseView
logger = logging.getLogger(__name__)
class AdminBaseView(BaseView):
login_required = True
async def _iter(self):
if not self.session.get("logged_in") or not self.session.get("uid"):
logger.warning("Unauthorized admin access attempt: not logged in")
return web.HTTPFound("/login.html")
user = await self.services.user.get(uid=self.session.get("uid"))
if not user or not user["is_admin"]:
logger.warning(f"Forbidden admin access attempt by user {self.session.get('uid')}")
return web.HTTPForbidden(text="Admin access required")
return await super()._iter()
-148
View File
@@ -1,148 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminChannelsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin channels list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
search = self.request.query.get("search", "")
result = await self.services.admin.list_channels(page=page, search=search if search else None)
return await self.render_template(
"admin/channels/index.html",
{
"channels": result["channels"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"search": search,
}
)
class AdminChannelMembersView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel members accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
members = await self.services.admin.get_channel_members(channel_uid)
return await self.render_template(
"admin/channels/members.html",
{
"channel": channel,
"members": members,
}
)
class AdminChannelEditView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel edit accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/edit.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel edit POST for {channel_uid} by {self.session.get('uid')}")
try:
data = await self.request.post()
updates = {}
if "name" in data:
updates["name"] = data["name"]
if "description" in data:
updates["description"] = data["description"]
if "is_private" in data:
updates["is_private"] = data["is_private"] == "on"
await self.services.admin.update_channel(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid,
updates=updates
)
return web.HTTPFound("/admin/channels/index.html")
except ValueError as ex:
logger.warning(f"Channel update error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/edit.html",
{"channel": channel, "error": str(ex)}
)
class AdminChannelClearView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel clear accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/clear.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel clear POST for {channel_uid} by {self.session.get('uid')}")
try:
result = await self.services.admin.clear_channel_history(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid
)
return web.HTTPFound(f"/admin/channels/index.html?cleared={result['deleted_count']}")
except ValueError as ex:
logger.warning(f"Channel clear error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/clear.html",
{"channel": channel, "error": str(ex)}
)
class AdminChannelDeleteView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel delete accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/delete.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel delete POST for {channel_uid} by {self.session.get('uid')}")
try:
await self.services.admin.delete_channel(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid
)
return web.HTTPFound("/admin/channels/index.html")
except ValueError as ex:
logger.warning(f"Channel delete error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/delete.html",
{"channel": channel, "error": str(ex)}
)
-110
View File
@@ -1,110 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminFilesIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin files list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.list_drives(
user_uid=user_uid if user_uid else None,
page=page
)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/files/index.html",
{
"drives": result["drives"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"user_uid": user_uid,
"users": users_result["users"],
}
)
class AdminDriveItemsView(AdminBaseView):
async def get(self):
drive_uid = self.request.match_info.get("drive_uid")
logger.info(f"Admin drive items accessed for {drive_uid} by {self.session.get('uid')}")
drive = await self.services.drive.get(uid=drive_uid)
if not drive:
return web.HTTPNotFound(text="Drive not found")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_drive_items(drive_uid=drive_uid, page=page)
user = await self.services.user.get(uid=drive["user_uid"])
return await self.render_template(
"admin/files/items.html",
{
"drive": drive,
"user": user,
"items": result["items"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
drive_uid = self.request.match_info.get("drive_uid")
logger.info(f"Admin drive item delete POST for {drive_uid} by {self.session.get('uid')}")
data = await self.request.post()
item_uid = data.get("item_uid")
if item_uid:
try:
await self.services.admin.delete_drive_item(
admin_uid=self.session.get("uid"),
item_uid=item_uid
)
except ValueError as ex:
logger.warning(f"Drive item delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminRepositoriesView(AdminBaseView):
async def get(self):
logger.info(f"Admin repositories list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.list_repositories(
user_uid=user_uid if user_uid else None,
page=page
)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/files/repositories.html",
{
"repositories": result["repositories"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"user_uid": user_uid,
"users": users_result["users"],
}
)
async def post(self):
logger.info(f"Admin repository delete POST by {self.session.get('uid')}")
data = await self.request.post()
repo_uid = data.get("repo_uid")
if repo_uid:
try:
await self.services.admin.delete_repository(
admin_uid=self.session.get("uid"),
repo_uid=repo_uid
)
except ValueError as ex:
logger.warning(f"Repository delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
-139
View File
@@ -1,139 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminForumsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin forums list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_forums(page=page)
return await self.render_template(
"admin/forums/index.html",
{
"forums": result["forums"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
logger.info(f"Admin forum action POST by {self.session.get('uid')}")
data = await self.request.post()
forum_uid = data.get("forum_uid")
action = data.get("action")
if forum_uid and action:
try:
if action == "delete":
await self.services.admin.delete_forum(
admin_uid=self.session.get("uid"),
forum_uid=forum_uid
)
elif action == "toggle_active":
forum = await self.services.forum.get(uid=forum_uid)
if forum:
await self.services.admin.update_forum(
admin_uid=self.session.get("uid"),
forum_uid=forum_uid,
updates={"is_active": not forum.get("is_active", True)}
)
except ValueError as ex:
logger.warning(f"Forum action error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminThreadsView(AdminBaseView):
async def get(self):
forum_uid = self.request.match_info.get("forum_uid")
logger.info(f"Admin threads list accessed for forum {forum_uid} by {self.session.get('uid')}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
return web.HTTPNotFound(text="Forum not found")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
return await self.render_template(
"admin/forums/threads.html",
{
"forum": forum,
"threads": result["threads"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
forum_uid = self.request.match_info.get("forum_uid")
logger.info(f"Admin thread action POST for forum {forum_uid} by {self.session.get('uid')}")
data = await self.request.post()
thread_uid = data.get("thread_uid")
action = data.get("action")
if thread_uid and action:
try:
if action == "delete":
await self.services.admin.delete_thread(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
elif action == "toggle_pin":
await self.services.admin.toggle_thread_pin(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
elif action == "toggle_lock":
await self.services.admin.toggle_thread_lock(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
except ValueError as ex:
logger.warning(f"Thread action error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminPostsView(AdminBaseView):
async def get(self):
thread_uid = self.request.match_info.get("thread_uid")
logger.info(f"Admin posts list accessed for thread {thread_uid} by {self.session.get('uid')}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
return web.HTTPNotFound(text="Thread not found")
forum = await self.services.forum.get(uid=thread["forum_uid"])
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_posts(thread_uid=thread_uid, page=page)
return await self.render_template(
"admin/forums/posts.html",
{
"forum": forum,
"thread": thread,
"posts": result["posts"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
thread_uid = self.request.match_info.get("thread_uid")
logger.info(f"Admin post action POST for thread {thread_uid} by {self.session.get('uid')}")
data = await self.request.post()
post_uid = data.get("post_uid")
action = data.get("action")
if post_uid and action == "delete":
try:
await self.services.admin.delete_post(
admin_uid=self.session.get("uid"),
post_uid=post_uid
)
except ValueError as ex:
logger.warning(f"Post delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
-22
View File
@@ -1,22 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin dashboard accessed by user {self.session.get('uid')}")
stats = await self.services.admin.get_dashboard_stats()
activity = await self.services.admin.get_recent_activity(limit=10)
return await self.render_template(
"admin/index.html",
{
"stats": stats,
"activity": activity,
}
)
-55
View File
@@ -1,55 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminMessagesIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin messages list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
query = self.request.query.get("query", "")
channel_uid = self.request.query.get("channel_uid", "")
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.search_messages(
query=query if query else None,
channel_uid=channel_uid if channel_uid else None,
user_uid=user_uid if user_uid else None,
page=page
)
channels_result = await self.services.admin.list_channels(per_page=100)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/messages/index.html",
{
"messages": result["messages"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"query": query,
"channel_uid": channel_uid,
"user_uid": user_uid,
"channels": channels_result["channels"],
"users": users_result["users"],
}
)
async def post(self):
logger.info(f"Admin message delete POST by {self.session.get('uid')}")
data = await self.request.post()
message_uid = data.get("message_uid")
if message_uid:
try:
await self.services.admin.delete_message(
admin_uid=self.session.get("uid"),
message_uid=message_uid
)
except ValueError as ex:
logger.warning(f"Message delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
-58
View File
@@ -1,58 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminNotificationsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin notifications list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_notifications(page=page)
return await self.render_template(
"admin/notifications/index.html",
{
"notifications": result["notifications"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
class AdminNotificationCreateView(AdminBaseView):
async def get(self):
logger.info(f"Admin notification create accessed by user {self.session.get('uid')}")
users_result = await self.services.admin.list_users(per_page=200)
return await self.render_template(
"admin/notifications/create.html",
{"users": users_result["users"]}
)
async def post(self):
logger.info(f"Admin notification create POST by {self.session.get('uid')}")
data = await self.request.post()
message = data.get("message", "").strip()
target = data.get("target", "all")
user_uids = data.getall("user_uids", []) if target == "selected" else None
try:
result = await self.services.admin.mass_notify(
admin_uid=self.session.get("uid"),
message=message,
user_uids=user_uids
)
return web.HTTPFound(f"/admin/notifications/index.html?sent={result['sent_count']}")
except ValueError as ex:
logger.warning(f"Mass notify error: {ex}")
users_result = await self.services.admin.list_users(per_page=200)
return await self.render_template(
"admin/notifications/create.html",
{"users": users_result["users"], "error": str(ex), "message": message}
)
-117
View File
@@ -1,117 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminSystemIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin system index accessed by user {self.session.get('uid')}")
stats = await self.services.admin.get_dashboard_stats()
return await self.render_template(
"admin/system/index.html",
{
"stats": stats,
"uptime": self.app.uptime,
}
)
async def post(self):
logger.info(f"Admin system action POST by {self.session.get('uid')}")
data = await self.request.post()
action = data.get("action")
message = None
if action == "maintenance":
try:
result = await self.services.admin.run_maintenance(
admin_uid=self.session.get("uid")
)
message = result.get("message", "Maintenance completed")
except Exception as ex:
logger.warning(f"Maintenance error: {ex}")
message = f"Error: {ex}"
stats = await self.services.admin.get_dashboard_stats()
return await self.render_template(
"admin/system/index.html",
{
"stats": stats,
"uptime": self.app.uptime,
"message": message,
}
)
class AdminKVView(AdminBaseView):
async def get(self):
logger.info(f"Admin KV store accessed by user {self.session.get('uid')}")
entries = await self.services.admin.get_kv_entries()
return await self.render_template(
"admin/system/kv.html",
{"entries": entries}
)
async def post(self):
logger.info(f"Admin KV store POST by {self.session.get('uid')}")
data = await self.request.post()
action = data.get("action")
try:
if action == "set":
key = data.get("key", "").strip()
value = data.get("value", "").strip()
await self.services.admin.set_kv_entry(
admin_uid=self.session.get("uid"),
key=key,
value=value
)
elif action == "delete":
key = data.get("key", "").strip()
await self.services.admin.delete_kv_entry(
admin_uid=self.session.get("uid"),
key=key
)
except ValueError as ex:
logger.warning(f"KV store error: {ex}")
entries = await self.services.admin.get_kv_entries()
return await self.render_template(
"admin/system/kv.html",
{"entries": entries, "error": str(ex)}
)
return web.HTTPFound("/admin/system/kv.html")
class AdminPushView(AdminBaseView):
async def get(self):
logger.info(f"Admin push registrations accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_push_registrations(page=page)
return await self.render_template(
"admin/system/push.html",
{
"registrations": result["registrations"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
logger.info(f"Admin push registration delete POST by {self.session.get('uid')}")
data = await self.request.post()
registration_uid = data.get("registration_uid")
if registration_uid:
try:
await self.services.admin.delete_push_registration(
admin_uid=self.session.get("uid"),
registration_uid=registration_uid
)
except ValueError as ex:
logger.warning(f"Push registration delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
-109
View File
@@ -1,109 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminUsersIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin users list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
search = self.request.query.get("search", "")
result = await self.services.admin.list_users(page=page, search=search if search else None)
return await self.render_template(
"admin/users/index.html",
{
"users": result["users"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"search": search,
}
)
class AdminUserEditView(AdminBaseView):
async def get(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user edit accessed for {user_uid} by {self.session.get('uid')}")
target_user = await self.services.user.get(uid=user_uid)
if not target_user:
return web.HTTPNotFound(text="User not found")
return await self.render_template(
"admin/users/edit.html",
{"target_user": target_user}
)
async def post(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user edit POST for {user_uid} by {self.session.get('uid')}")
try:
data = await self.request.post()
updates = {}
if "nick" in data:
updates["nick"] = data["nick"]
if "is_admin" in data:
updates["is_admin"] = data["is_admin"] == "on"
if "is_banned" in data:
updates["is_banned"] = data["is_banned"] == "on"
if "color" in data:
updates["color"] = data["color"]
await self.services.admin.update_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid,
updates=updates
)
return web.HTTPFound("/admin/users/index.html")
except ValueError as ex:
logger.warning(f"User update error: {ex}")
target_user = await self.services.user.get(uid=user_uid)
return await self.render_template(
"admin/users/edit.html",
{"target_user": target_user, "error": str(ex)}
)
class AdminUserBanView(AdminBaseView):
async def get(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user ban accessed for {user_uid} by {self.session.get('uid')}")
target_user = await self.services.user.get(uid=user_uid)
if not target_user:
return web.HTTPNotFound(text="User not found")
return await self.render_template(
"admin/users/ban.html",
{"target_user": target_user}
)
async def post(self):
user_uid = self.request.match_info.get("user_uid")
data = await self.request.post()
action = data.get("action", "ban")
logger.info(f"Admin user {action} POST for {user_uid} by {self.session.get('uid')}")
try:
if action == "unban":
await self.services.admin.unban_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid
)
else:
await self.services.admin.ban_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid
)
return web.HTTPFound("/admin/users/index.html")
except ValueError as ex:
logger.warning(f"User ban error: {ex}")
target_user = await self.services.user.get(uid=user_uid)
return await self.render_template(
"admin/users/ban.html",
{"target_user": target_user, "error": str(ex)}
)
+4
View File
@@ -203,12 +203,16 @@ class ChannelAttachmentUploadView(BaseView):
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(attachment["path"], "wb") as file:
print("File openend.", filename)
async for msg in ws:
if msg.type == web.WSMsgType.BINARY:
print("Binary",filename)
if file is not None:
await file.write(msg.data)
await ws.send_json({"type": "progress", "filename": filename, "bytes": await file.tell()})
elif msg.type == web.WSMsgType.TEXT:
print("TExt",filename)
print(msg.json())
data = msg.json()
if data.get('type') == 'end':
relative_url = urllib.parse.quote(attachment["relative_url"])
+1247
View File
File diff suppressed because it is too large Load Diff
-275
View File
@@ -1,275 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import random
import time
from aiohttp import web
from snek.system.view import BaseView
from snek.view.rpc.base import BaseAction
from snek.view.rpc.utils import safe_get, safe_str
from snek.view.rpc.actions import (
AdminActions,
AuthActions,
UserActions,
ChannelActions,
MessageActions,
MentionActions,
ContainerActions,
DatabaseActions,
MiscActions,
)
logger = logging.getLogger(__name__)
class RPCApi(
BaseAction,
AdminActions,
AuthActions,
UserActions,
ChannelActions,
MessageActions,
MentionActions,
ContainerActions,
DatabaseActions,
MiscActions,
):
def __init__(self, view, ws):
self.view = view
self.app = safe_get(view, "app")
self.services = safe_get(self.app, "services") if self.app else None
self.ws = ws
self.user_session = {}
self._scheduled_tasks = []
self._finalize_task = None
self._is_closed = False
async def __call__(self, data):
call_id = None
start_time = time.time()
try:
if self._is_closed:
logger.debug("RPC call on closed connection")
return
if not data or not isinstance(data, dict):
logger.warning("Invalid RPC data received")
return
call_id = data.get("callId")
method_name = data.get("method")
args = data.get("args", [])
logger.debug(f"RPC call received: method={method_name}, callId={call_id}, args_count={len(args) if args else 0}, user_uid={self.user_uid}")
if not method_name or not isinstance(method_name, str):
logger.warning(f"Invalid method name: {method_name}")
await self._send_json({"callId": call_id, "success": False, "data": "Invalid method"})
return
if method_name.startswith("_"):
logger.warning(f"Attempted to call private method: {method_name}")
await self._send_json({"callId": call_id, "success": False, "data": "Not allowed"})
return
if args is None:
args = []
elif not isinstance(args, (list, tuple)):
args = [args]
safe_method_name = method_name.replace(".", "_")
if not hasattr(self, safe_method_name):
logger.warning(f"Method not found: {method_name}")
await self._send_json({"callId": call_id, "success": False, "data": "Method not found"})
return
method = getattr(self, safe_method_name, None)
if not method or not callable(method):
logger.warning(f"Method not callable: {method_name}")
await self._send_json({"callId": call_id, "success": False, "data": "Method not callable"})
return
success = True
result = None
try:
logger.debug(f"Executing RPC method: {method_name}")
result = await method(*args)
elapsed = (time.time() - start_time) * 1000
logger.info(f"RPC method completed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, success=True")
except (PermissionError, ValueError) as ex:
result = {"error": safe_str(ex)}
success = False
elapsed = (time.time() - start_time) * 1000
logger.warning(f"RPC method permission/value error: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
except Exception as ex:
result = {"error": "Internal error"}
success = False
elapsed = (time.time() - start_time) * 1000
logger.exception(f"RPC method failed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
if result != "noresponse":
await self._send_json({"callId": call_id, "success": success, "data": result})
except Exception as ex:
logger.exception(f"RPC call failed: {safe_str(ex)}")
try:
await self._send_json({"callId": call_id, "success": False, "data": "Internal error"})
except Exception:
pass
async def _send_json(self, obj):
if self._is_closed:
return False
if not self.ws:
return False
try:
json_str = json.dumps(obj, default=str)
await self.ws.send_str(json_str)
return True
except ConnectionResetError:
self._is_closed = True
logger.debug("Connection reset during send")
except Exception as ex:
self._is_closed = True
logger.warning(f"Failed to send JSON: {safe_str(ex)}")
try:
if self.services and hasattr(self.services, "socket"):
await self.services.socket.delete(self.ws)
except Exception:
pass
return False
class RPCView(BaseView):
RPCApi = RPCApi
async def get(self):
ws = None
rpc = None
scheduled_tasks = []
async def schedule(uid, seconds, call):
if not uid or not call:
return
try:
seconds = max(0, min(float(seconds) if seconds else 0, 300))
await asyncio.sleep(seconds)
if self.services:
await self.services.socket.send_to_user(uid, call)
except asyncio.CancelledError:
pass
except Exception as ex:
logger.debug(f"Schedule failed: {safe_str(ex)}")
try:
ws = web.WebSocketResponse(heartbeat=300.0, autoping=True)
await ws.prepare(self.request)
except Exception as ex:
logger.warning(f"Failed to prepare WebSocket: {safe_str(ex)}")
return web.Response(status=500, text="WebSocket initialization failed")
user_uid = None
try:
session = getattr(self.request, "session", None)
if session and session.get("logged_in"):
user_uid = session.get("uid")
if user_uid and self.services:
await self.services.socket.add(ws, user_uid)
# Load subscriptions in background to avoid blocking WebSocket handshake
async def load_subscriptions():
try:
async for subscription in self.services.channel_member.find(
user_uid=user_uid,
deleted_at=None,
is_banned=False,
):
if subscription:
channel_uid = safe_get(subscription, "channel_uid")
if channel_uid:
await self.services.socket.subscribe(ws, channel_uid, user_uid)
except Exception as ex:
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
asyncio.create_task(load_subscriptions())
except Exception as ex:
logger.warning(f"Session initialization failed: {safe_str(ex)}")
try:
app = getattr(self.request, "app", None)
uptime_seconds = getattr(app, "uptime_seconds", 999) if app else 999
if not scheduled_tasks and uptime_seconds < 15 and user_uid:
jitter = random.uniform(0, 3)
task1 = asyncio.create_task(schedule(
user_uid, jitter,
{"event": "refresh", "data": {"message": "Finishing deployment"}},
))
scheduled_tasks.append(task1)
uptime = getattr(app, "uptime", "") if app else ""
task2 = asyncio.create_task(schedule(
user_uid, 15 + jitter,
{"event": "deployed", "data": {"uptime": uptime}},
))
scheduled_tasks.append(task2)
except Exception as ex:
logger.debug(f"Deployment schedule failed: {safe_str(ex)}")
rpc = RPCApi(self, ws)
try:
async for msg in ws:
if msg is None:
continue
try:
if msg.type == web.WSMsgType.TEXT:
try:
data = msg.json()
if data:
await rpc(data)
except json.JSONDecodeError as ex:
logger.warning(f"Invalid JSON received: {safe_str(ex)}")
except Exception as ex:
logger.warning(f"RPC call failed: {safe_str(ex)}")
elif msg.type == web.WSMsgType.BINARY:
logger.debug("Binary message received, ignoring")
elif msg.type == web.WSMsgType.ERROR:
logger.debug(f"WebSocket error: {safe_str(ws.exception())}")
break
elif msg.type == web.WSMsgType.CLOSE:
logger.debug("WebSocket close message received")
break
elif msg.type == web.WSMsgType.CLOSING:
logger.debug("WebSocket closing")
break
elif msg.type == web.WSMsgType.CLOSED:
logger.debug("WebSocket closed")
break
except Exception as ex:
logger.warning(f"Message processing error: {safe_str(ex)}")
except asyncio.CancelledError:
logger.debug("WebSocket handler cancelled")
except ConnectionResetError:
logger.debug("Connection reset by peer")
except Exception as ex:
logger.warning(f"WebSocket loop error: {safe_str(ex)}")
finally:
if rpc:
rpc._is_closed = True
if rpc._finalize_task:
try:
rpc._finalize_task.cancel()
except Exception:
pass
for task in rpc._scheduled_tasks:
try:
if task and not task.done():
task.cancel()
except Exception:
pass
for task in scheduled_tasks:
try:
if task and not task.done():
task.cancel()
except Exception:
pass
try:
if self.services and ws:
await self.services.socket.delete(ws)
except Exception as ex:
logger.debug(f"Socket cleanup failed: {safe_str(ex)}")
try:
if ws and not ws.closed:
await ws.close()
except Exception:
pass
return ws
-23
View File
@@ -1,23 +0,0 @@
# retoor <retoor@molodetz.nl>
from snek.view.rpc.actions.admin import AdminActions
from snek.view.rpc.actions.auth import AuthActions
from snek.view.rpc.actions.user import UserActions
from snek.view.rpc.actions.channel import ChannelActions
from snek.view.rpc.actions.message import MessageActions
from snek.view.rpc.actions.mention import MentionActions
from snek.view.rpc.actions.container import ContainerActions
from snek.view.rpc.actions.database import DatabaseActions
from snek.view.rpc.actions.misc import MiscActions
__all__ = [
"AdminActions",
"AuthActions",
"UserActions",
"ChannelActions",
"MessageActions",
"MentionActions",
"ContainerActions",
"DatabaseActions",
"MiscActions",
]
-347
View File
@@ -1,347 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_str
logger = logging.getLogger(__name__)
class AdminActions:
async def _require_admin(self):
self._require_login()
self._require_services()
user = await self.services.user.get(uid=self.user_uid)
if not user or not user["is_admin"]:
raise PermissionError("Admin access required")
return True
async def admin_get_stats(self):
try:
await self._require_admin()
stats = await self.services.admin.get_dashboard_stats()
return {"success": True, "data": stats}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_stats failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get stats"}
async def admin_get_recent_activity(self, limit=20):
try:
await self._require_admin()
activity = await self.services.admin.get_recent_activity(limit=limit)
return {"success": True, "data": activity}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_recent_activity failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get activity"}
async def admin_list_users(self, page=1, per_page=20, search=None):
try:
await self._require_admin()
result = await self.services.admin.list_users(page=page, per_page=per_page, search=search)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_users failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list users"}
async def admin_update_user(self, target_uid, updates):
try:
await self._require_admin()
result = await self.services.admin.update_user(
admin_uid=self.user_uid,
target_uid=target_uid,
updates=updates
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_update_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to update user"}
async def admin_ban_user(self, target_uid):
try:
await self._require_admin()
result = await self.services.admin.ban_user(
admin_uid=self.user_uid,
target_uid=target_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_ban_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to ban user"}
async def admin_unban_user(self, target_uid):
try:
await self._require_admin()
result = await self.services.admin.unban_user(
admin_uid=self.user_uid,
target_uid=target_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_unban_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to unban user"}
async def admin_list_channels(self, page=1, per_page=20, search=None):
try:
await self._require_admin()
result = await self.services.admin.list_channels(page=page, per_page=per_page, search=search)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_channels failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list channels"}
async def admin_get_channel_members(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.get_channel_members(channel_uid)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_channel_members failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get channel members"}
async def admin_update_channel(self, channel_uid, updates):
try:
await self._require_admin()
result = await self.services.admin.update_channel(
admin_uid=self.user_uid,
channel_uid=channel_uid,
updates=updates
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_update_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to update channel"}
async def admin_delete_channel(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_channel(
admin_uid=self.user_uid,
channel_uid=channel_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete channel"}
async def admin_clear_channel(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.clear_channel_history(
admin_uid=self.user_uid,
channel_uid=channel_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_clear_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to clear channel"}
async def admin_search_messages(self, query=None, channel_uid=None, user_uid=None, page=1):
try:
await self._require_admin()
result = await self.services.admin.search_messages(
query=query,
channel_uid=channel_uid,
user_uid=user_uid,
page=page
)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_search_messages failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to search messages"}
async def admin_delete_message(self, message_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_message(
admin_uid=self.user_uid,
message_uid=message_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_message failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete message"}
async def admin_list_forums(self, page=1):
try:
await self._require_admin()
result = await self.services.admin.list_forums(page=page)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_forums failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list forums"}
async def admin_delete_forum(self, forum_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_forum(
admin_uid=self.user_uid,
forum_uid=forum_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_forum failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete forum"}
async def admin_list_threads(self, forum_uid, page=1):
try:
await self._require_admin()
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_threads failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list threads"}
async def admin_toggle_thread_pin(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.toggle_thread_pin(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_toggle_thread_pin failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to toggle pin"}
async def admin_toggle_thread_lock(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.toggle_thread_lock(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_toggle_thread_lock failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to toggle lock"}
async def admin_delete_thread(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_thread(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_thread failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete thread"}
async def admin_delete_post(self, post_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_post(
admin_uid=self.user_uid,
post_uid=post_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_post failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete post"}
async def admin_mass_notify(self, message, user_uids=None):
try:
await self._require_admin()
result = await self.services.admin.mass_notify(
admin_uid=self.user_uid,
message=message,
user_uids=user_uids
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_mass_notify failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to send notifications"}
async def admin_get_kv_entries(self):
try:
await self._require_admin()
result = await self.services.admin.get_kv_entries()
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_kv_entries failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get KV entries"}
async def admin_set_kv_entry(self, key, value):
try:
await self._require_admin()
result = await self.services.admin.set_kv_entry(
admin_uid=self.user_uid,
key=key,
value=value
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_set_kv_entry failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to set KV entry"}
async def admin_delete_kv_entry(self, key):
try:
await self._require_admin()
result = await self.services.admin.delete_kv_entry(
admin_uid=self.user_uid,
key=key
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_kv_entry failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete KV entry"}
async def admin_run_maintenance(self):
try:
await self._require_admin()
result = await self.services.admin.run_maintenance(admin_uid=self.user_uid)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_run_maintenance failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to run maintenance"}
-57
View File
@@ -1,57 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class AuthActions:
async def login(self, username, password):
self._require_services()
if not username or not isinstance(username, str):
raise ValueError("Invalid username")
if not password or not isinstance(password, str):
raise ValueError("Invalid password")
try:
success = await self.services.user.validate_login(username, password)
if not success:
raise PermissionError("Invalid username or password")
user = await self.services.user.get(username=username)
if not user:
raise PermissionError("User not found")
if not self.view or not hasattr(self.view, "session") or self.view.session is None:
raise RuntimeError("Session not available")
self.view.session["uid"] = safe_get(user, "uid")
self.view.session["logged_in"] = True
self.view.session["username"] = safe_get(user, "username", "")
self.view.session["user_nick"] = safe_get(user, "nick", "")
record = dict(user.record) if hasattr(user, "record") else {}
record.pop("password", None)
record.pop("deleted_at", None)
user_uid = None
if self.view and hasattr(self.view, "session") and self.view.session:
user_uid = self.view.session.get("uid") if hasattr(self.view.session, "get") else None
if user_uid:
await self.services.socket.add(self.ws, user_uid)
try:
async for subscription in self.services.channel_member.find(
user_uid=user_uid,
deleted_at=None,
is_banned=False,
):
if subscription and subscription["channel_uid"]:
await self.services.socket.subscribe(
self.ws,
subscription["channel_uid"],
user_uid,
)
except Exception as ex:
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
return record
except (PermissionError, ValueError):
raise
except Exception as ex:
logger.error(f"Login error: {safe_str(ex)}")
raise PermissionError("Login failed")
-229
View File
@@ -1,229 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.system.model import now
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class ChannelActions:
async def get_channels(self):
self._require_login()
self._require_services()
channels = []
try:
async for subscription in self.services.channel_member.find(
user_uid=self.user_uid, is_banned=False
):
if not subscription:
continue
try:
channel_uid = subscription["channel_uid"]
if not channel_uid:
continue
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
continue
color = None
try:
last_message = await channel.get_last_message()
if last_message:
last_message_user = await last_message.get_user()
color = safe_get(last_message_user, "color") if last_message_user else None
except Exception:
pass
other_user = await self.services.channel_member.get_other_dm_user(
subscription["channel_uid"], self.user_uid
)
name = other_user and other_user['nick'] or subscription['label']
channels.append({
"name": name,
"uid": channel_uid,
"tag": channel["tag"] or "",
"new_count": subscription["new_count"] or 0,
"is_moderator": subscription["is_moderator"] or False,
"is_read_only": subscription["is_read_only"] or False,
"color": color,
})
except Exception as ex:
logger.warning(f"Failed to process channel subscription: {safe_str(ex)}")
return channels
except Exception as ex:
logger.warning(f"Failed to get channels: {safe_str(ex)}")
return []
async def create_channel(self, name, description=None, is_private=False):
self._require_login()
self._require_services()
if not name or not isinstance(name, str):
return {"error": "Invalid channel name", "success": False}
name = name.strip()
if not name:
return {"error": "Channel name cannot be empty", "success": False}
try:
channel = await self.services.channel.create(
label=name,
created_by_uid=self.user_uid,
description=description,
is_private=is_private,
is_listed=True
)
if not channel:
return {"error": "Failed to create channel", "success": False}
await self.services.channel_member.create(
channel["uid"],
self.user_uid,
is_moderator=True
)
await self.services.socket.subscribe(self.ws, channel["uid"], self.user_uid)
return {
"success": True,
"uid": channel["uid"],
"name": channel["label"]
}
except Exception as ex:
logger.warning(f"Failed to create channel: {safe_str(ex)}")
return {"error": safe_str(ex), "success": False}
async def update_channel(self, channel_uid, name, description=None, is_private=False):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
if not name or not isinstance(name, str):
return {"error": "Invalid channel name", "success": False}
name = name.strip()
if not name:
return {"error": "Channel name cannot be empty", "success": False}
try:
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot modify the public channel", "success": False}
is_creator = channel.get("created_by_uid") == self.user_uid
user = await self.services.user.get(uid=self.user_uid)
is_admin = user and user.get("is_admin", False)
if not is_creator and not is_admin:
return {"error": "Only the channel creator or admin can update channel settings", "success": False}
if not name.startswith("#") and channel["is_listed"]:
name = f"#{name}"
channel["label"] = name
channel["description"] = description
channel["is_private"] = is_private
await self.services.channel.save(channel)
async for member in self.services.channel_member.find(
channel_uid=channel_uid, deleted_at=None
):
member["label"] = name
await self.services.channel_member.save(member)
return {"success": True, "name": channel["label"]}
except Exception as ex:
logger.warning(f"Failed to update channel: {safe_str(ex)}")
return {"error": "Failed to update channel", "success": False}
async def delete_channel(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
try:
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot delete the public channel", "success": False}
is_creator = channel.get("created_by_uid") == self.user_uid
user = await self.services.user.get(uid=self.user_uid)
is_admin = user and user.get("is_admin", False)
if not is_creator and not is_admin:
return {"error": "Only the channel creator or admin can delete channels", "success": False}
channel["deleted_at"] = now()
await self.services.channel.save(channel)
async for member in self.services.channel_member.find(
channel_uid=channel_uid, deleted_at=None
):
member["deleted_at"] = now()
await self.services.channel_member.save(member)
return {"success": True}
except Exception as ex:
logger.warning(f"Failed to delete channel: {safe_str(ex)}")
return {"error": "Failed to delete channel", "success": False}
async def leave_channel(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
try:
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot leave the public channel", "success": False}
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return {"error": "Not a member of this channel", "success": False}
channel_member["deleted_at"] = now()
await self.services.channel_member.save(channel_member)
await self.services.socket.unsubscribe(self.ws, channel_uid, self.user_uid)
return {"success": True}
except Exception as ex:
logger.warning(f"Failed to leave channel: {safe_str(ex)}")
return {"error": "Failed to leave channel", "success": False}
async def invite_user(self, channel_uid, username):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
if not username or not isinstance(username, str):
return {"error": "Invalid username", "success": False}
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not a member of this channel")
user = await self.services.user.get(username=username.strip())
if not user:
return {"error": "User not found", "success": False}
existing = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=user["uid"]
)
if existing and not existing["deleted_at"]:
return {"error": "User is already a member", "success": False}
result = await self.services.channel_member.create(
channel_uid,
user["uid"],
is_moderator=False
)
if not result:
return {"error": "Failed to invite user", "success": False}
return {"success": True, "username": user["username"]}
except PermissionError as ex:
return {"error": safe_str(ex), "success": False}
except Exception as ex:
logger.warning(f"Failed to invite user: {safe_str(ex)}")
return {"error": "Failed to invite user", "success": False}
async def clear_channel(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
raise ValueError("Invalid channel_uid")
user = await self.services.user.get(uid=self.user_uid)
if not user or not safe_get(user, "is_admin", False):
raise PermissionError("Not allowed")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
channel["history_start"] = now()
await self.services.channel.save(channel)
return await self.services.channel_message.clear(channel_uid)
-146
View File
@@ -1,146 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import time
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class ContainerActions:
async def get_container(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return None
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not allowed")
container = await self.services.container.get(channel_uid)
if not container:
return None
deploy = safe_get(container, "deploy", {})
resources = safe_get(deploy, "resources", {})
limits = safe_get(resources, "limits", {})
return {
"name": await self.services.container.get_container_name(channel_uid),
"cpus": safe_get(limits, "cpus", "0.5"),
"memory": safe_get(limits, "memory", "256M"),
"image": "ubuntu:latest",
"volumes": [],
"status": safe_get(container, "status", "unknown"),
}
except PermissionError:
raise
except Exception as ex:
logger.warning(f"Failed to get container: {safe_str(ex)}")
return None
async def write_container(self, channel_uid, content, timeout=3):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
raise ValueError("Invalid channel_uid")
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not allowed")
container_name = await self.services.container.get_container_name(channel_uid)
if not container_name:
raise RuntimeError("Container not found")
timeout = max(1, min(int(timeout) if timeout else 3, 30))
class SessionCall:
def __init__(self, app, channel_uid, container_name):
self.app = app
self.channel_uid = channel_uid
self.container_name = container_name
self.time_last_output = time.time()
self.output = b""
async def stdout_event_handler(self, data):
try:
self.time_last_output = time.time()
if data:
self.output += data if isinstance(data, bytes) else data.encode("utf-8", "ignore")
return True
except Exception:
return False
async def communicate(self, content, timeout=3):
try:
if self.app and hasattr(self.app, "services") and self.app.services:
await self.app.services.container.add_event_listener(
self.container_name, "stdout", self.stdout_event_handler
)
await self.app.services.container.write_stdin(self.channel_uid, content)
start_time = time.time()
while time.time() - self.time_last_output < timeout:
if time.time() - start_time > timeout * 2:
break
await asyncio.sleep(0.1)
await self.app.services.container.remove_event_listener(
self.container_name, "stdout", self.stdout_event_handler
)
return self.output
except Exception as ex:
logger.warning(f"Container communication error: {safe_str(ex)}")
return self.output
sc = SessionCall(self.app, channel_uid, container_name)
result = await sc.communicate(content, timeout)
return result.decode("utf-8", "ignore") if result else ""
except (PermissionError, ValueError):
raise
except Exception as ex:
logger.warning(f"Failed to write container: {safe_str(ex)}")
return ""
async def start_container(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
raise ValueError("Invalid channel_uid")
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not allowed")
return await self.services.container.start(channel_uid)
async def stop_container(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
raise ValueError("Invalid channel_uid")
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not allowed")
return await self.services.container.stop(channel_uid)
async def get_container_status(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return None
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not allowed")
return await self.services.container.get_status(channel_uid)
except PermissionError:
raise
except Exception as ex:
logger.warning(f"Failed to get container status: {safe_str(ex)}")
return None
-95
View File
@@ -1,95 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_str
logger = logging.getLogger(__name__)
class DatabaseActions:
async def db_insert(self, table_name, record):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
if not record or not isinstance(record, dict):
raise ValueError("Invalid record")
return await self.services.db.insert(self.user_uid, table_name, record)
async def db_update(self, table_name, record):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
if not record or not isinstance(record, dict):
raise ValueError("Invalid record")
return await self.services.db.update(self.user_uid, table_name, record)
async def db_delete(self, table_name, record):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
if not record or not isinstance(record, dict):
raise ValueError("Invalid record")
return await self.services.db.delete(self.user_uid, table_name, record)
async def db_get(self, table_name, record):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
return await self.services.db.get(self.user_uid, table_name, record)
async def db_find(self, table_name, record):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
return await self.services.db.find(self.user_uid, table_name, record)
async def db_upsert(self, table_name, record, keys):
self._require_login()
self._require_services()
if not table_name or not isinstance(table_name, str):
raise ValueError("Invalid table name")
if not record or not isinstance(record, dict):
raise ValueError("Invalid record")
return await self.services.db.upsert(
self.user_uid, table_name, record, keys
)
async def db_query(self, sql, args):
self._require_login()
self._require_services()
if not sql or not isinstance(sql, str):
raise ValueError("Invalid SQL query")
return await self.services.db.query(self.user_uid, sql, args)
async def query(self, *args):
self._require_login()
self._require_services()
if not args or len(args) == 0:
return []
query_str = args[0]
if not query_str or not isinstance(query_str, str):
return []
lowercase = query_str.lower()
forbidden_keywords = ["drop", "alter", "update", "delete", "replace", "insert", "truncate"]
if any(keyword in lowercase for keyword in forbidden_keywords) and "select" not in lowercase:
raise PermissionError("Not allowed")
try:
results = []
async for record in self.services.channel.query(query_str):
if record:
record_dict = dict(record)
record_dict.pop("email", None)
record_dict.pop("password", None)
record_dict.pop("message", None)
record_dict.pop("html", None)
results.append(record_dict)
return results
except Exception as ex:
logger.warning(f"Query failed: {safe_str(ex)}")
return []
-78
View File
@@ -1,78 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class MentionActions:
async def mark_as_read(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return False
try:
await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)
return True
except Exception as ex:
logger.warning(f"Failed to mark as read: {safe_str(ex)}")
return False
async def get_unread_mention_count(self, channel_uid=None):
self._require_login()
self._require_services()
try:
if channel_uid and not isinstance(channel_uid, str):
return 0
return await self.services.mention.get_unread_count(
self.user_uid, channel_uid
)
except Exception as ex:
logger.warning(f"Failed to get unread mention count: {safe_str(ex)}")
return 0
async def get_next_unread_mention(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return None
try:
mention = await self.services.mention.get_oldest_unread_mention(
self.user_uid, channel_uid
)
if mention:
return {
"mention_uid": safe_get(mention, "uid"),
"message_uid": safe_get(mention, "message_uid"),
"channel_uid": safe_get(mention, "channel_uid")
}
return None
except Exception as ex:
logger.warning(f"Failed to get next unread mention: {safe_str(ex)}")
return None
async def mark_mention_read(self, mention_uid):
self._require_login()
self._require_services()
if not mention_uid or not isinstance(mention_uid, str):
return False
try:
return await self.services.mention.mark_read(mention_uid, self.user_uid)
except Exception as ex:
logger.warning(f"Failed to mark mention read: {safe_str(ex)}")
return False
async def mark_all_mentions_read(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return 0
try:
return await self.services.mention.mark_all_read_in_channel(
self.user_uid, channel_uid
)
except Exception as ex:
logger.warning(f"Failed to mark all mentions read: {safe_str(ex)}")
return 0
-268
View File
@@ -1,268 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from snek.system.model import now
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class MessageActions:
async def set_typing(self, channel_uid, color=None):
logger.debug(f"set_typing: called channel_uid={channel_uid}, user_uid={self.user_uid}, color={color}")
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
logger.warning(f"set_typing: invalid channel_uid: {channel_uid}")
return False
try:
user = await self.services.user.get(uid=self.user_uid)
if not user:
logger.warning(f"set_typing: user not found: {self.user_uid}")
return False
if not color:
color = safe_get(user, "color", "#000000")
logger.debug(f"set_typing: broadcasting typing indicator to channel={channel_uid}, user={safe_get(user, 'username')}")
result = await self.services.socket.broadcast(
channel_uid,
{
"channel_uid": channel_uid,
"event": "set_typing",
"data": {
"event": "set_typing",
"user_uid": safe_get(user, "uid", ""),
"username": safe_get(user, "username", ""),
"nick": safe_get(user, "nick", ""),
"channel_uid": channel_uid,
"color": color,
},
},
)
logger.debug(f"set_typing: broadcast result={result}")
return result
except Exception as ex:
logger.warning(f"Failed to set typing: {safe_str(ex)}")
return False
async def get_messages(self, channel_uid, offset=0, timestamp=None):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return []
try:
offset = int(offset) if offset else 0
if offset < 0:
offset = 0
messages = []
result = await self.services.channel_message.offset(
channel_uid, offset, timestamp or None
)
for message in (result or []):
if message:
try:
extended_dict = await self.services.channel_message.to_extended_dict(message)
if extended_dict:
messages.append(extended_dict)
except Exception as ex:
logger.warning(f"Failed to extend message: {safe_str(ex)}")
return messages
except Exception as ex:
logger.warning(f"Failed to get messages: {safe_str(ex)}")
return []
async def get_first_unread_message_uid(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return None
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return None
last_read_at = safe_get(channel_member, "last_read_at")
if not last_read_at:
return None
async for message in self.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
{"channel_uid": channel_uid, "last_read_at": last_read_at}
):
return safe_get(message, "uid")
return None
except Exception as ex:
logger.warning(f"Failed to get first unread message: {safe_str(ex)}")
return None
async def _finalize_message_task(self, message_uid):
if not message_uid:
return False
try:
for _ in range(7):
await asyncio.sleep(1)
if not self._finalize_task:
return False
except asyncio.CancelledError:
logger.debug("Finalization cancelled")
return False
except Exception as ex:
logger.warning(f"Finalization wait error: {safe_str(ex)}")
return False
self._finalize_task = None
try:
if not self.services:
return False
message = await self.services.channel_message.get(message_uid, is_final=False)
if not message:
return False
if safe_get(message, "user_uid") != self.user_uid:
return False
if safe_get(message, "is_final", False):
return True
await self.services.chat.finalize(safe_get(message, "uid"))
return True
except Exception as ex:
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
return False
async def _queue_finalize_message(self, message_uid):
if not message_uid:
return
try:
if self._finalize_task:
self._finalize_task.cancel()
try:
await self._finalize_task
except asyncio.CancelledError:
pass
except Exception:
pass
self._finalize_task = asyncio.create_task(
self._finalize_message_task(message_uid)
)
except Exception as ex:
logger.warning(f"Failed to queue finalize: {safe_str(ex)}")
async def send_message(self, channel_uid, message, is_final=True):
logger.debug(f"send_message called: channel_uid={channel_uid}, message_len={len(str(message)) if message else 0}, is_final={is_final}, user_uid={self.user_uid}")
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
logger.warning(f"send_message: invalid channel_uid: {channel_uid}")
return None
if message is None:
logger.debug("send_message: message is None")
return None
try:
message_text = safe_str(message).strip()
logger.debug(f"send_message: processing message text, length={len(message_text)}, is_final={is_final}")
if not is_final:
logger.debug(f"send_message: checking for existing non-final message in channel={channel_uid}")
check_message = await self.services.channel_message.get(
channel_uid=channel_uid, user_uid=self.user_uid, is_final=False, deleted_at=None
)
if check_message:
msg_uid = safe_get(check_message, "uid")
logger.debug(f"send_message: found existing non-final message uid={msg_uid}, updating text")
await self._queue_finalize_message(msg_uid)
return await self.update_message_text(msg_uid, message_text)
if not message_text:
logger.debug("send_message: empty message text after strip")
return None
logger.info(f"send_message: sending new message to channel={channel_uid}, is_final={is_final}")
result = await self.services.chat.send(
self.user_uid, channel_uid, message_text, is_final
)
if not result:
logger.warning(f"send_message: chat.send returned None for channel={channel_uid}")
return None
result_uid = safe_get(result, "uid")
result_is_final = safe_get(result, "is_final", True)
logger.info(f"send_message: message sent successfully, uid={result_uid}, is_final={result_is_final}")
if not result_is_final:
await self._queue_finalize_message(result_uid)
elif self._finalize_task:
self._finalize_task.cancel()
return result_uid
except Exception as ex:
logger.warning(f"Failed to send message: {safe_str(ex)}", exc_info=True)
return None
async def finalize_message(self, message_uid):
self._require_login()
self._require_services()
if not message_uid or not isinstance(message_uid, str):
return False
try:
message = await self.services.channel_message.get(message_uid)
if not message:
return False
if safe_get(message, "user_uid") != self.user_uid:
raise PermissionError("Not allowed")
if not safe_get(message, "is_final", False):
await self.services.chat.finalize(safe_get(message, "uid"))
return True
except PermissionError:
raise
except Exception as ex:
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
return False
async def update_message_text(self, message_uid, text):
self._require_login()
self._require_services()
if not message_uid or not isinstance(message_uid, str):
return {"error": "Invalid message_uid", "success": False}
try:
message = await self.services.channel_message.get(message_uid)
if not message:
return {"error": "Message not found", "success": False}
if safe_get(message, "is_final", False):
return {"error": "Message is final", "success": False}
if safe_get(message, "deleted_at"):
return {"error": "Message is deleted", "success": False}
if safe_get(message, "user_uid") != self.user_uid:
raise PermissionError("Not allowed")
seconds_since_update = 0
try:
if hasattr(message, "get_seconds_since_last_update"):
seconds_since_update = message.get_seconds_since_last_update()
except Exception:
pass
if seconds_since_update > 8:
return {
"error": "Message too old",
"seconds_since_last_update": seconds_since_update,
"success": False,
}
text_str = safe_str(text) if text else ""
if not text_str:
message["deleted_at"] = now()
else:
message["deleted_at"] = None
message["message"] = text_str
await self.services.channel_message.save(message)
record = dict(message.record) if hasattr(message, "record") else {}
record["text"] = safe_get(message, "message", "")
record["message_uid"] = message_uid
channel_uid = safe_get(message, "channel_uid")
if channel_uid:
try:
await self.services.socket.broadcast(
channel_uid,
{
"channel_uid": channel_uid,
"event": "update_message_text",
"data": record,
},
)
except Exception as ex:
logger.warning(f"Failed to broadcast message update: {safe_str(ex)}")
return {"success": True}
except PermissionError:
raise
except Exception as ex:
logger.warning(f"Failed to update message text: {safe_str(ex)}")
return {"error": "Update failed", "success": False}
-78
View File
@@ -1,78 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from snek.system.model import now
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class MiscActions:
async def echo(self, *args):
return args
async def echo_raw(self, obj):
if not self.ws:
return "noresponse"
try:
await self.ws.send_json(obj if obj else {})
except Exception as ex:
logger.warning(f"Failed to echo: {safe_str(ex)}")
return "noresponse"
async def ping(self, call_id=None, *args):
try:
if self.user_uid and self.services:
user = await self.services.user.get(uid=self.user_uid)
if user:
user["last_ping"] = now()
await self.services.user.save(user)
return {"pong": args if args else []}
except Exception as ex:
logger.warning(f"Ping failed: {safe_str(ex)}")
return {"pong": []}
async def _schedule(self, seconds, call):
if not call:
return
try:
seconds = max(0, min(float(seconds) if seconds else 0, 300))
await asyncio.sleep(seconds)
if self.services and self.user_uid and not self._is_closed:
await self.services.socket.send_to_user(self.user_uid, call)
except asyncio.CancelledError:
pass
except Exception as ex:
logger.warning(f"Schedule failed: {safe_str(ex)}")
async def stars_render(self, channel_uid, message):
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return False
try:
users = await self.get_online_users(channel_uid)
for user in (users or []):
if not user:
continue
user_uid = safe_get(user, "uid")
if not user_uid:
continue
try:
await self.services.socket.send_to_user(
user_uid,
{
"event": "stars_render",
"data": {
"channel_uid": channel_uid,
"message": safe_str(message) if message else "",
},
},
)
except Exception as ex:
logger.debug(f"Failed to send stars_render to user: {safe_str(ex)}")
return True
except Exception as ex:
logger.warning(f"stars_render failed: {safe_str(ex)}")
return False
-97
View File
@@ -1,97 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class UserActions:
async def get_user(self, user_uid=None):
self._require_login()
self._require_services()
try:
if not user_uid:
user_uid = self.user_uid
if not user_uid:
return None
user = await self.services.user.get(uid=user_uid)
if not user:
return None
record = dict(user.record) if hasattr(user, "record") else {}
record.pop("password", None)
record.pop("deleted_at", None)
if user_uid != self.user_uid:
record.pop("email", None)
return record
except Exception as ex:
logger.warning(f"Failed to get user: {safe_str(ex)}")
return None
async def search_user(self, query):
self._require_login()
self._require_services()
if not query or not isinstance(query, str):
return []
try:
results = await self.services.user.search(query)
return [safe_get(user, "username", "") for user in (results or []) if user]
except Exception as ex:
logger.warning(f"Failed to search user: {safe_str(ex)}")
return []
async def get_online_users(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return []
try:
results = []
async for record in self.services.channel.get_recent_users(channel_uid):
if record:
results.append(record)
return sorted(results, key=lambda x: safe_get(x, "nick", ""))
except Exception as ex:
logger.warning(f"Failed to get online users: {safe_str(ex)}")
return []
async def get_recent_users(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return []
try:
results = []
async for record in self.services.channel.get_recent_users(channel_uid):
if record:
results.append({
"uid": safe_get(record, "uid", ""),
"username": safe_get(record, "username", ""),
"nick": safe_get(record, "nick", ""),
"last_ping": safe_get(record, "last_ping"),
})
return results
except Exception as ex:
logger.warning(f"Failed to get recent users: {safe_str(ex)}")
return []
async def get_users(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return []
try:
results = []
async for record in self.services.channel.get_users(channel_uid):
if record:
results.append({
"uid": safe_get(record, "uid", ""),
"username": safe_get(record, "username", ""),
"nick": safe_get(record, "nick", ""),
"last_ping": safe_get(record, "last_ping"),
})
return results
except Exception as ex:
logger.warning(f"Failed to get users: {safe_str(ex)}")
return []
-80
View File
@@ -1,80 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_get, safe_str
logger = logging.getLogger(__name__)
class BaseAction:
@property
def user_uid(self):
try:
if not self.view or not hasattr(self.view, "session"):
return None
session = self.view.session
if session is None:
return None
if hasattr(session, "get"):
return session.get("uid")
return None
except Exception:
return None
@property
def request(self):
return safe_get(self.view, "request")
@property
def is_logged_in(self):
try:
if not self.view or not hasattr(self.view, "session"):
return False
session = self.view.session
if session is None:
return False
return bool(session.get("logged_in", False))
except Exception:
return False
def _require_login(self):
if not self.is_logged_in:
raise PermissionError("Not logged in")
def _require_services(self):
if not self.services:
raise RuntimeError("Services not available")
async def _session_ensure(self):
try:
uid = await self.view.session_get("uid") if self.view else None
if uid and uid not in self.user_session:
self.user_session[uid] = {"said_hello": False}
except Exception as ex:
logger.warning(f"Failed to ensure session: {safe_str(ex)}")
async def session_get(self, key, default=None):
try:
await self._session_ensure()
uid = self.user_uid
if not uid or uid not in self.user_session:
return default
return self.user_session[uid].get(key, default)
except Exception as ex:
logger.warning(f"Failed to get session key {key}: {safe_str(ex)}")
return default
async def session_set(self, key, value):
try:
await self._session_ensure()
uid = self.user_uid
if not uid:
return False
if uid not in self.user_session:
self.user_session[uid] = {}
self.user_session[uid][key] = value
return True
except Exception as ex:
logger.warning(f"Failed to set session key {key}: {safe_str(ex)}")
return False
-24
View File
@@ -1,24 +0,0 @@
# retoor <retoor@molodetz.nl>
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 ""
-28
View File
@@ -1,28 +0,0 @@
# retoor <retoor@molodetz.nl>
from snek.system.view import BaseView
class ArchitectureView(BaseView):
async def get(self):
return await self.render_template("site/architecture.html")
class DesignView(BaseView):
async def get(self):
return await self.render_template("site/design.html")
class ApiView(BaseView):
async def get(self):
return await self.render_template("site/api.html")
class BotsView(BaseView):
async def get(self):
return await self.render_template("site/bots.html")
class ContributeView(BaseView):
async def get(self):
return await self.render_template("site/contribute.html")