Compare commits

...
Author SHA1 Message Date
retoor bde3819510 Update. 2025-06-01 00:24:45 +02:00
retoor 097889ba3f Make database asnyc. 2025-05-31 23:30:41 +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
203 changed files with 14751 additions and 1099 deletions
+7 -1
View File
@@ -1,10 +1,16 @@
.r_history
.vscode
.history
.resources
.backup*
docs
snek.d*
.rcontext.txt
*.zip
*.db*
*.png
cache
drive
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
+4 -35
View File
@@ -1,41 +1,10 @@
FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf
FROM python:3.12.8-alpine3.21
FROM python:3.14.0a6-bookworm
RUN mkdir -p /code
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers git
#WKHTMLTOPDFNEEDS
RUN apk add --no-cache \
libstdc++ \
libx11 \
libxrender \
libxext \
libssl3 \
ca-certificates \
fontconfig \
freetype \
ttf-dejavu \
ttf-droid \
ttf-freefont \
ttf-liberation \
# more fonts
&& apk add --no-cache --virtual .build-deps \
msttcorefonts-installer \
# Install microsoft fonts
&& update-ms-fonts \
&& fc-cache -f \
# Clean up when done
&& rm -rf /tmp/* \
&& apk del .build-deps
COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf
COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage
COPY setup.cfg setup.cfg
RUN apt update && apt install build-essential docker -y
COPY pyproject.toml pyproject.toml
COPY src src
RUN mkdir /drive
RUN pip install --upgrade pip
RUN pip install -e .
EXPOSE 8081
CMD ["python","-m","snek.app"]
#CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12.8-alpine3.21
WORKDIR /code
RUN apk add --no-cache gcc musl-dev linux-headers git openssh
#WKHTMLTOPDFNEEDS
COPY pyproject.toml pyproject.toml
COPY src src
COPY ssh_host_key ssh_host_key
RUN pip install --upgrade pip
RUN pip install -e .
EXPOSE 2225
#CMD ["python","-m","snekssh.app"]
#CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
+12
View File
@@ -0,0 +1,12 @@
FROM ubuntu:latest
RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx tmux -y
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain nightly -y
RUN wget https://retoor.molodetz.nl/api/packages/retoor/generic/r/1.0.0/r
RUN chmod +x r
RUN mv r /usr/local/bin/r
+18 -4
View File
@@ -6,12 +6,26 @@ GUNICORN_WORKERS = 1
PORT = 8081
install:
python3 -m venv .venv
$(PIP) install -e .
shell:
.venv/bin/snek shell
dump:
@$(PYTHON) -m snek.dump
build:
serve: run
run:
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
.venv/bin/snek serve
install: ubuntu
python3.12 -m venv .venv
$(PIP) install -e .
ubuntu:
docker build -f DockerfileUbuntu -t snek_ubuntu .
+9 -2
View File
@@ -2,10 +2,17 @@ services:
snek:
build: .
restart: always
privileged: true
ports:
- "8081:8081"
volumes:
- ./:/code
- /media/storage/snek.molodetz.nl/drive:/code/drive
- /media/storage/snek.molodetz.nl/drive:/drive
- /var/run/docker.sock:/var/run/docker.sock
environment:
- PYTHONDONTWRITEBYTECODE=1
- PYTHONUNBUFFERED=1
entrypoint: ["gunicorn", "-w", "1", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
#["python","-m","snek.app"]
#entrypoint: ["python","-m","snek.app"]
+27 -4
View File
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "Snek"
version = "1.0.0"
readme = "README.md"
license = { file = "LICENSE", content-type="text/markdown" }
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"
authors = [
{ name = "retoor", email = "retoor@molodetz.nl" }
@@ -15,13 +15,36 @@ keywords = ["chat", "snek", "molodetz"]
requires-python = ">=3.12"
dependencies = [
"mkdocs>=1.4.0",
"lxml",
"IPython",
"shed",
"app @ git+https://retoor.molodetz.nl/retoor/app",
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
"beautifulsoup4",
"gunicorn",
"imgkit",
"wkhtmltopdf",
"jinja-markdown2",
"mistune"
"mistune",
"aiohttp-session",
"cryptography",
"requests",
"asyncssh",
"emoji",
"aiofiles",
"PyJWT",
"multiavatar",
"gitpython",
'uvloop; platform_system != "Windows"',
"humanize",
"Pillow",
"pillow-heif",
]
[tool.setuptools.packages.find]
where = ["src"] # <-- this changed
[tool.setuptools.package-data]
"*" = ["*.*"]
[project.scripts]
snek = "snek.__main__:main"
-29
View File
@@ -1,29 +0,0 @@
[metadata]
name = snek
version = 1.0.0
description = Snek chat server
author = retoor
author_email = retoor@molodetz.nl
license = MIT
long_description = file: README.md
long_description_content_type = text/markdown
[options]
packages = find:
package_dir =
= src
python_requires = >=3.7
install_requires =
app @ git+https://retoor.molodetz.nl/retoor/app
beautifulsoup4
gunicorn
imgkit
wkhtmltopdf
shed
[options.packages.find]
where = src
[options.entry_points]
console_scripts =
snek.serve = snek.server:cli
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
import click
import uvloop
from aiohttp import web
import asyncio
from snek.app import Application
from IPython import start_ipython
import sqlite3
import pathlib
import shutil
@click.group()
def cli():
pass
@cli.command()
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.')
@click.option('--source',default=None, help='Database to initialize if not exists.')
def init(db_path,source):
if source and pathlib.Path(source).exists():
print(f"Copying {source} to {db_path}")
shutil.copy2(source,db_path)
print("Database initialized.")
return
if pathlib.Path(db_path).exists():
return
print(f"Initializing database at {db_path}")
db = sqlite3.connect(db_path)
db.cursor().executescript(
pathlib.Path(__file__).parent.joinpath("schema.sql").read_text()
)
db.commit()
db.close()
print("Database initialized.")
@cli.command()
@click.option('--port', default=8081, show_default=True, help='Port to run the application on')
@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
def serve(port, host, db_path):
#init(db_path)
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
web.run_app(
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
)
@cli.command()
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
def shell(db_path):
app = Application(db_path=f"sqlite:///{db_path}")
start_ipython(argv=[], user_ns={'app': app})
def main():
cli()
if __name__ == "__main__":
main()
+313 -22
View File
@@ -1,22 +1,81 @@
import asyncio
import logging
import pathlib
import time
import uuid
from datetime import datetime
from snek import snode
from snek.view.threads import ThreadsView
import json
logging.basicConfig(level=logging.DEBUG)
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
from aiohttp_session import (
get_session as session_get,
session_middleware,
setup as session_setup,
)
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
from app.cache import time_cache_async
from jinja_markdown2 import MarkdownExtension
from jinja2 import FileSystemLoader
from snek.sssh import start_ssh_server
from snek.docs.app import Application as DocsApplication
from snek.mapper import get_mappers
from snek.service import get_services
from snek.system import http
from snek.system.middleware import cors_middleware
from snek.system.cache import Cache
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware
from snek.system.profiler import profiler_handler
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.avatar import AvatarView
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.drive import DriveView
from snek.view.drive import DriveApiView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.login_form import LoginFormView
from snek.view.logout import LogoutView
from snek.view.register import RegisterView
from snek.view.register_form import RegisterFormView
from snek.view.rpc import RPCView
from snek.view.repository import RepositoryView
from snek.view.search_user import SearchUserView
from snek.view.settings.repositories import RepositoriesIndexView
from snek.view.settings.repositories import RepositoriesCreateView
from snek.view.settings.repositories import RepositoriesUpdateView
from snek.view.settings.repositories import RepositoriesDeleteView
from snek.view.settings.index import SettingsIndexView
from snek.view.settings.profile import SettingsProfileView
from snek.view.stats import StatsView
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from types import SimpleNamespace
from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication
from snek.sgit import GitApplication
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
@web.middleware
async def session_middleware(request, handler):
setattr(request, "session", await session_get(request))
response = await handler(request)
return response
@web.middleware
async def trailing_slash_middleware(request, handler):
if request.path and not request.path.endswith("/"):
# Redirect to the same path with a trailing slash
raise web.HTTPFound(request.path + "/")
return await handler(request)
class Application(BaseApplication):
@@ -27,16 +86,108 @@ class Application(BaseApplication):
web.normalize_path_middleware(merge_slashes=True),
]
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
super().__init__(
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs
)
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
self.tasks = asyncio.Queue()
self._middlewares.append(session_middleware)
self._middlewares.append(auth_middleware)
self.jinja2_env.add_extension(MarkdownExtension)
self.jinja2_env.add_extension(LinkifyExtension)
self.jinja2_env.add_extension(PythonExtension)
self.jinja2_env.add_extension(EmojiExtension)
self.time_start = datetime.now()
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
self.setup_router()
self.setup_services()
self.ssh_server = None
self.sync_service = None
self.executor = None
self.cache = Cache(self)
self.services = get_services(app=self)
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
@property
def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds()
def setup_services(self):
self.services = SimpleNamespace(**get_services(app=self))
self.mappers = SimpleNamespace(**get_mappers(app=self))
@property
def uptime(self):
return self._format_uptime(self.uptime_seconds)
def _format_uptime(self,seconds):
seconds = int(seconds)
days, seconds = divmod(seconds, 86400)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
parts = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes > 0:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds > 0 or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
return ", ".join(parts)
async def start_user_availability_service(self, app):
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
async def snode_sync(self, app):
self.sync_service = asyncio.create_task(snode.sync_service(app))
async def start_ssh_server(self, app):
app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)
if app.ssh_server:
asyncio.create_task(app.ssh_server.wait_closed())
async def prepare_asyncio(self, app):
# app.loop = asyncio.get_running_loop()
app.executor = ThreadPoolExecutor(max_workers=200)
app.loop.set_default_executor(self.executor)
async def create_task(self, task):
await self.tasks.put(task)
async def task_runner(self):
while True:
task = await self.tasks.get()
self.db.begin()
try:
await task
self.tasks.task_done()
except Exception as ex:
print(ex)
self.db.commit()
async def prepare_database(self, app):
self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off")
try:
if not self.db["user"].has_index("username"):
self.db["user"].create_index("username", unique=True)
if not self.db["channel_member"].has_index(["channel_uid", "user_uid"]):
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
except:
pass
await app.services.drive.prepare_all()
self.loop.create_task(self.task_runner())
def setup_router(self):
self.router.add_get("/", IndexView)
@@ -46,19 +197,60 @@ class Application(BaseApplication):
name="static",
show_index=True,
)
self.router.add_view("/profiler.html", profiler_handler)
self.router.add_view("/about.html", AboutHTMLView)
self.router.add_view("/about.md", AboutMDView)
self.router.add_view("/logout.json", LogoutView)
self.router.add_view("/logout.html", LogoutView)
self.router.add_view("/docs.html", DocsHTMLView)
self.router.add_view("/docs.md", DocsMDView)
self.router.add_view("/status.json", StatusView)
self.router.add_view("/settings/index.html", SettingsIndexView)
self.router.add_view("/settings/profile.html", SettingsProfileView)
self.router.add_view("/settings/profile.json", SettingsProfileView)
self.router.add_view("/web.html", WebView)
self.router.add_view("/login.html", LoginView)
self.router.add_view("/login.json", LoginFormView)
self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterFormView)
self.router.add_view("/register.json", RegisterView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/drive.bin", UploadView)
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView)
self.router.add_get("/http-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView)
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view("/repository/{username}/{repository}/{path:.*}", RepositoryView)
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git",self.git)
#self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
return await self.render_template(
@@ -76,15 +268,114 @@ class Application(BaseApplication):
return web.Response(
body=path.read_bytes(), headers={"Content-Type": "image/png"}
)
#@time_cache_async(60)
# @time_cache_async(60)
async def render_template(self, template, request, context=None):
return await super().render_template(template, request, context)
channels = []
if not context:
context = {}
context["rid"] = str(uuid.uuid4())
if request.session.get("uid"):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
)
last_message = await parent_object.get_last_message()
color = None
if last_message:
last_message_user = await last_message.get_user()
color = last_message_user["color"]
item["color"] = color
item["last_message_on"] = parent_object["last_message_on"]
item["is_private"] = parent_object["tag"] == "dm"
if other_user:
item["name"] = other_user["nick"]
item["uid"] = subscribed_channel["channel_uid"]
else:
item["name"] = subscribed_channel["label"]
item["uid"] = subscribed_channel["channel_uid"]
item["new_count"] = subscribed_channel["new_count"]
channels.append(item)
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
if "channels" not in context:
context["channels"] = channels
if "user" not in context:
context["user"] = await self.services.user.get(
request.session.get("uid")
)
self.template_path.joinpath(template)
await self.services.user.get_template_path(request.session.get("uid"))
self.original_loader = self.jinja2_env.loader
self.jinja2_env.loader = await self.get_user_template_loader(
request.session.get("uid")
)
rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader
return rendered
app = Application()
async def static_handler(self, request):
file_name = request.match_info.get('filename', '')
paths = []
uid = request.session.get("uid")
if uid:
user_static_path = await self.services.user.get_static_path(uid)
if user_static_path:
paths.append(user_static_path)
for admin_uid in self.services.user.get_admin_uids():
user_static_path = await self.services.user.get_static_path(admin_uid)
if user_static_path:
paths.append(user_static_path)
paths.append(self.static_path)
for path in paths:
if pathlib.Path(path).joinpath(file_name).exists():
return web.FileResponse(pathlib.Path(path).joinpath(file_name))
return web.HTTPNotFound()
async def get_user_template_loader(self, uid=None):
template_paths = []
for admin_uid in self.services.user.get_admin_uids():
user_template_path = await self.services.user.get_template_path(admin_uid)
if user_template_path:
template_paths.append(user_template_path)
if uid:
user_template_path = await self.services.user.get_template_path(uid)
if user_template_path:
template_paths.append(user_template_path)
template_paths.append(self.template_path)
return FileSystemLoader(template_paths)
app = Application(db_path="sqlite:///snek.db")
async def main():
await web._run_app(app, port=8081, host="0.0.0.0")
if __name__ == "__main__":
web.run_app(app, port=8081, host="0.0.0.0")
asyncio.run(main())
+123
View File
@@ -0,0 +1,123 @@
import asyncio
import sys
class LoadBalancer:
def __init__(self, backend_ports):
self.backend_ports = backend_ports
self.backend_processes = []
self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock()
async def start_backend_servers(self,port,workers):
for x in range(workers):
port += 1
process = await asyncio.create_subprocess_exec(
sys.executable,
sys.argv[0],
'backend',
str(port),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
port += 1
self.backend_processes.append(process)
print(f"Started backend server on port {(port-1)/port} with PID {process.pid}")
async def handle_client(self, reader, writer):
async with self.lock:
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ('127.0.0.1', self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
async def forward(r, w):
try:
while True:
data = await r.read(1024)
if not data:
break
w.write(data)
await w.drain()
except asyncio.CancelledError:
pass
finally:
w.close()
task1 = asyncio.create_task(forward(reader, backend_writer))
task2 = asyncio.create_task(forward(backend_reader, writer))
await asyncio.gather(task1, task2)
except Exception as e:
print(f"Error: {e}")
finally:
writer.close()
async with self.lock:
self.client_counts[server_index] -= 1
async def monitor(self):
while True:
await asyncio.sleep(5)
print("Connected clients per server:")
for i, count in enumerate(self.client_counts):
print(f"Server {self.backend_ports[i]}: {count} clients")
async def start(self, host='0.0.0.0', port=8081,workers=5):
await self.start_backend_servers(port,workers)
server = await asyncio.start_server(self.handle_client, host, port)
monitor_task = asyncio.create_task(self.monitor())
# Handle shutdown gracefully
try:
async with server:
await server.serve_forever()
except asyncio.CancelledError:
pass
finally:
# Terminate backend processes
for process in self.backend_processes:
process.terminate()
await asyncio.gather(*(p.wait() for p in self.backend_processes))
print("Backend processes terminated.")
async def backend_echo_server(port):
async def handle_echo(reader, writer):
try:
while True:
data = await reader.read(1024)
if not data:
break
writer.write(data)
await writer.drain()
except Exception:
pass
finally:
writer.close()
server = await asyncio.start_server(handle_echo, '127.0.0.1', port)
print(f"Backend echo server running on port {port}")
await server.serve_forever()
async def main():
backend_ports = [8001, 8003, 8005, 8006]
# Launch backend echo servers
# Wait a moment for servers to start
lb = LoadBalancer(backend_ports)
await lb.start()
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == 'backend':
port = int(sys.argv[2])
from snek.app import Application
snek = Application(port=port)
web.run_app(snek, port=port, host='127.0.0.1')
elif sys.argv[1] == 'sync':
from snek.sync import app
web.run_app(snek, port=port, host='127.0.0.1')
else:
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Shutting down...")
+43
View File
@@ -0,0 +1,43 @@
import pathlib
from aiohttp import web
from app.app import Application as BaseApplication
from snek.system.markdown import MarkdownExtension
class Application(BaseApplication):
def __init__(self, path=None, *args, **kwargs):
self.path = pathlib.Path(path)
template_path = self.path
super().__init__(template_path=template_path, *args, **kwargs)
self.jinja2_env.add_extension(MarkdownExtension)
self.router.add_get("/{tail:.*}", self.handle_document)
async def handle_document(self, request):
relative_path = request.match_info["tail"].strip("/")
if relative_path == "":
relative_path = "index.html"
document_path = self.path.joinpath(relative_path)
if not document_path.exists():
return web.Response(
status=404,
body=b"Resource is not found on this server.",
content_type="text/plain",
)
if document_path.is_dir():
document_path = document_path.joinpath("index.html")
if not document_path.exists():
return web.Response(
status=404,
body=b"Resource is not found on this server.",
content_type="text/plain",
)
response = await self.render_template(
str(document_path.relative_to(self.path)), request
)
return response
+61
View File
@@ -0,0 +1,61 @@
{% extends "docs/base.html" %}
{% block main %}
{% markdown %}
# API Documentation
Currently only some details about the internal API are available.
## How to create a user
```python
# Save user to the table named 'user'
# Password gets sha256 encrypted with default a salt string
# of the snek.system.security module.
new_user_object = await app.service.user.register(
username="retoor",
password="retoorded"
)
```
## Encrypt string
```python
from snek.system import security
# Support for both utf and bytes.
var1 = security.encrypt("data")
var2 = security.encrypt(b"data")
# Is correct:
assert(var1 == var2)
```
## How to create a basic HTML / Markdown view
```python
from snek.system.view import BaseView
class IndexView(BaseView):
async def get(self):
# The render function supports markdown.
# It will render with syntax highlighting.
# Just use the .md file extension in the file name.
return await self.render("index.html")
```
## How to create a FormView
```python
from snek.system.view import BaseFormView
from snek.form.register import RegisterForm
class RegisterFormView(BaseFormView):
form = RegisterForm
```
## How to register a class view
```python
app.routes.add_view("/your-page.html", YourViewClass)
```
{% endmarkdown %}
{% endblock %}
+116
View File
@@ -0,0 +1,116 @@
<html>
<head>
<style>{{ highlight_styles }}</style>
<style>
* {
box-sizing: border-box;
}
.dialog {
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
width: 800px;
margin: 30px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
}
@media screen and (max-width: 500px) {
.center {
width: 100%;
left: 0px;
}
.dialog {
width: 100%;
left: 0px;
}
}
h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
}
h2 {
font-size: 1.4em;
color: #f05a28;
margin-bottom: 20px;
}
html,body,main {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
width: 100%;
}
article {
max-width: 100%;
width: 60%;
background-color: #0f0f0f;
padding: 30px;
min-height: 100vh;
word-break: break-all;
}
footer {
position: fixed;
width: 60%;
text-align: center;
bottom: 0;
left: 20%;
background-color: #000;
}
a {
color: #f05a28;
display: block;
margin-top: 15px;
font-size: 0.9em;
transition: color 0.3s;
}
header {
text-align: left;
width: 60%;
padding: 30px;
}
header a {
display: inline;
}
div {
text-align: left;
}
</style>
</head>
<body>
<main>
<header>
<a href="/">Snek</a>
<a href="/docs/docs">Docs</a>
</header>
<article>
{% block main %}
{% endblock %}
</article>
</main>
<footer>
{% markdown %}
[Respository](https://github.com/retoor/snek)
{% endmarkdown %}
</footer>
</body>
</html>
@@ -0,0 +1,17 @@
{% extends "docs/base.html" %}
{% block main %}
{% markdown %}
# Form API Javascript
## Dependencies
- generic-form.js
## Usage
It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.
```html
<generic-form url="/url-to-form-api"></generic-form>
```
{% endmarkdown %}
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends "docs/base.html" %}
{% block main %}
{% markdown %}
# Form API Javascript
## Dependencies
- `snek.system.form.Form`
- `snek.system.form.HTMLElement`
- `snek.system.form.FormInputElement`
- `snek.system.form.FormButtonElement`
## Usage
Here is an example with custom validation.
This example contains a field that checks if user already exists.
If invalid, it adds an error message which automatically invalidates the field.
Handling of the error messages will automatically done client side.
Forms are usaly located in `snek/form/[form name].py`.
```python
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
class UsernameField(FormInputElement):
@property
async def errors(self):
result = await super().errors
if self.value and await self.app.services.user.count(username=self.value):
result.append("Username is not available.")
return result
class RegisterForm(Form):
title = HTMLElement(tag="h1", text="Register")
username = UsernameField(
name="username",
required=True,
min_length=2,
max_length=20,
regex=r"^[a-zA-Z0-9_]+$",
place_holder="Username",
type="text"
)
email = FormInputElement(
name="email",
required=False,
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
place_holder="Email address",
type="email"
)
password = FormInputElement(
name="password",
required=True,
regex=r"^[a-zA-Z0-9_.+-]{6,}",
type="password",
place_holder="Password"
)
action = FormButtonElement(
name="action",
value="submit",
text="Register",
type="button"
)
```
## Set data
```python
# The input structure is in same format as output structure.
# Output structure is the result of await form.to_json()
data = dict(
username=dict(value="retoor"),
password=dict(value="retoorded")
)
form.set_user_data(data)
# Check if form is valid.
is_valid = await form.is_valid
# Convert form to a record (kv pairs) to be used for persistance.
# It does contain an filled uid (UUID4) field already to be used as primary key.
# Default fields:
# - uid (automatically generated, it's an UUID4 wich you can use as private key for database)
# - created_at (automatically generated, it's a string representation of UTC locale)
# - updated_at (execute await form.updated_at.update() before saving to set value)
key_value_values = await form.record
```
{% endmarkdown %}
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "docs/base.html" %}
{% block main %}
{% markdown %}
# Snek
# Introduction
Snek is a high customizable chat application.
It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!
This documentation is under construction. Only the form API and the small introduction is a bit documented.
## Quick API notes
[Small introduction / cheatsheet](/docs/docs/api.html)
## View API
With the view classes of Snek you can render HTML and Markdown
- [API Python](#)
- [API Javascript](#)
## ORM API
Snek's database model is based on Python dataset library.
Snek uses a model/mapper architecture build on top of that library.
- [API](#)
## Form API
Snek does have his own components for creating and rendering forms.
All forms are made server side and client side is generated client side using a HTML component.
It's client side only one line to include a form that can validate and submit.
Validation is server side using REST. Page won't refresh.
[API Python](/docs/docs/form_api_python.html)
[API Javascript](/docs/docs/form_api_javascript.html)
{% endmarkdown %}
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
import asyncio
from snek.app import app
async def fix_message(message):
message = {
"uid": message["uid"],
"user_uid": message["user_uid"],
"text": message["message"],
"sent": message["created_at"],
}
user = await app.services.user.get(uid=message["user_uid"])
message["user"] = user and user["username"] or None
return (message["user"] or "") + ": " + (message["text"] or "")
async def dump_public_channels():
result = []
for channel in app.db["channel"].find(
is_private=False, is_listed=True, tag="public"
):
print(f"Dumping channel: {channel['label']}.")
result += [
await fix_message(record)
for record in app.db["channel_message"].find(
channel_uid=channel["uid"], order_by="created_at"
)
]
print("Dump succesfull!")
print("Converting to json.")
print("Converting succesful, now writing to dump.json")
with open("dump.txt", "w") as f:
f.write("\n\n".join(result))
print("Dump written to dump.json")
if __name__ == "__main__":
asyncio.run(dump_public_channels())
+38 -11
View File
@@ -1,24 +1,51 @@
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
class AuthField(FormInputElement):
@property
async def errors(self):
result = await super().errors
if self.model.password.value and self.model.username.value:
if not await self.app.services.user.validate_login(
self.model.username.value, self.model.password.value
):
return ["Invalid username or password"]
return result
class LoginForm(Form):
title = HTMLElement(tag="h1", text="Login")
username = FormInputElement(
name="username",
username = AuthField(
name="username",
required=True,
min_length=2,
max_length=20,
regex=r"^[a-zA-Z0-9_]+$",
regex=r"^[a-zA-Z0-9_-]+$",
place_holder="Username",
type="text"
type="text",
)
password = AuthField(
name="password",
required=True,
min_length=1,
type="password",
place_holder="Password",
)
password = FormInputElement(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}",type="password",place_holder="Password")
action = FormButtonElement(
name="action",
value="submit",
text="Login",
type="button"
name="action", value="submit", text="Login", type="button"
)
@property
async def is_valid(self):
return all(
[
self["username"],
self["password"],
not await self.username.errors,
not await self.password.errors,
]
)
+15 -11
View File
@@ -1,4 +1,5 @@
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
class UsernameField(FormInputElement):
@@ -9,32 +10,35 @@ class UsernameField(FormInputElement):
result.append("Username is not available.")
return result
class RegisterForm(Form):
title = HTMLElement(tag="h1", text="Register")
username = UsernameField(
name="username",
name="username",
required=True,
min_length=2,
max_length=20,
regex=r"^[a-zA-Z0-9_]+$",
regex=r"^[a-zA-Z0-9_-]+$",
place_holder="Username",
type="text"
type="text",
)
email = FormInputElement(
name="email",
required=False,
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
place_holder="Email address",
type="email"
type="email",
)
password = FormInputElement(
name="password",
required=True,
min_length=1,
type="password",
place_holder="Password",
)
password = FormInputElement(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}",type="password",place_holder="Password")
action = FormButtonElement(
name="action",
value="submit",
text="Register",
type="button"
name="action", value="submit", text="Register", type="button"
)
+18
View File
@@ -0,0 +1,18 @@
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
class SearchUserForm(Form):
title = HTMLElement(tag="h1", text="Search user")
username = FormInputElement(
name="username",
required=True,
min_length=1,
max_length=128,
place_holder="Username",
)
action = FormButtonElement(
name="action", value="submit", text="Search", type="button"
)
+25
View File
@@ -0,0 +1,25 @@
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
class SettingsProfileForm(Form):
nick = FormInputElement(
name="nick",
required=True,
place_holder="Your Nickname",
min_length=1,
max_length=20,
)
action = FormButtonElement(
name="action", value="submit", text="Save", type="button"
)
title = HTMLElement(tag="h1", text="Profile")
profile = FormInputElement(
name="profile",
place_holder="Tell about yourself.",
required=False,
max_length=300,
)
action = FormButtonElement(
name="action", value="submit", text="Save", type="button"
)
+32 -6
View File
@@ -1,12 +1,38 @@
import functools
import functools
from snek.mapper.channel import ChannelMapper
from snek.mapper.channel_member import ChannelMemberMapper
from snek.mapper.channel_message import ChannelMessageMapper
from snek.mapper.drive import DriveMapper
from snek.mapper.drive_item import DriveItemMapper
from snek.mapper.notification import NotificationMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.channel_attachment import ChannelAttachmentMapper
from snek.mapper.container import ContainerMapper
from snek.system.object import Object
@functools.cache
@functools.cache
def get_mappers(app=None):
return dict(
user=UserMapper(app=app)
return Object(
**{
"user": UserMapper(app=app),
"channel_member": ChannelMemberMapper(app=app),
"channel": ChannelMapper(app=app),
"channel_message": ChannelMessageMapper(app=app),
"notification": NotificationMapper(app=app),
"drive_item": DriveItemMapper(app=app),
"drive": DriveMapper(app=app),
"user_property": UserPropertyMapper(app=app),
"repository": RepositoryMapper(app=app),
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
}
)
def get_mapper(name, app=None):
return get_mappers(app=app)[name]
return get_mappers(app=app)[name]
+7
View File
@@ -0,0 +1,7 @@
from snek.model.channel import ChannelModel
from snek.system.mapper import BaseMapper
class ChannelMapper(BaseMapper):
table_name = "channel"
model_class = ChannelModel
+7
View File
@@ -0,0 +1,7 @@
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.system.mapper import BaseMapper
class ChannelAttachmentMapper(BaseMapper):
table_name = "channel_attachment"
model_class = ChannelAttachmentModel
+7
View File
@@ -0,0 +1,7 @@
from snek.model.channel_member import ChannelMemberModel
from snek.system.mapper import BaseMapper
class ChannelMemberMapper(BaseMapper):
table_name = "channel_member"
model_class = ChannelMemberModel
+7
View File
@@ -0,0 +1,7 @@
from snek.model.channel_message import ChannelMessageModel
from snek.system.mapper import BaseMapper
class ChannelMessageMapper(BaseMapper):
model_class = ChannelMessageModel
table_name = "channel_message"
+6
View File
@@ -0,0 +1,6 @@
from snek.model.container import Container
from snek.system.mapper import BaseMapper
class ContainerMapper(BaseMapper):
model_class = Container
table_name = "container"
+7
View File
@@ -0,0 +1,7 @@
from snek.model.drive import DriveModel
from snek.system.mapper import BaseMapper
class DriveMapper(BaseMapper):
table_name = "drive"
model_class = DriveModel
+8
View File
@@ -0,0 +1,8 @@
from snek.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper
class DriveItemMapper(BaseMapper):
model_class = DriveItemModel
table_name = "drive_item"
+7
View File
@@ -0,0 +1,7 @@
from snek.model.notification import NotificationModel
from snek.system.mapper import BaseMapper
class NotificationMapper(BaseMapper):
table_name = "notification"
model_class = NotificationModel
+7
View File
@@ -0,0 +1,7 @@
from snek.model.repository import RepositoryModel
from snek.system.mapper import BaseMapper
class RepositoryMapper(BaseMapper):
model_class = RepositoryModel
table_name = "repository"
+16 -2
View File
@@ -1,6 +1,20 @@
from snek.system.mapper import BaseMapper
from snek.model.user import UserModel
from snek.system.mapper import BaseMapper
class UserMapper(BaseMapper):
table_name = "user"
model_class = UserModel
model_class = UserModel
def get_admin_uids(self):
try:
return [
user["uid"]
for user in self.db.query(
"SELECT uid FROM user WHERE is_admin = :is_admin",
{"is_admin": True},
)
]
except Exception as ex:
print(ex)
return []
+7
View File
@@ -0,0 +1,7 @@
from snek.model.user_property import UserPropertyModel
from snek.system.mapper import BaseMapper
class UserPropertyMapper(BaseMapper):
table_name = "user_property"
model_class = UserPropertyModel
+32 -5
View File
@@ -1,12 +1,39 @@
from snek.model.user import UserModel
import functools
import functools
from snek.model.channel import ChannelModel
from snek.model.channel_member import ChannelMemberModel
# from snek.model.channel_message import ChannelMessageModel
from snek.model.channel_message import ChannelMessageModel
from snek.model.drive import DriveModel
from snek.model.drive_item import DriveItemModel
from snek.model.notification import NotificationModel
from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
from snek.model.repository import RepositoryModel
from snek.model.channel_attachment import ChannelAttachmentModel
from snek.model.container import Container
from snek.system.object import Object
@functools.cache
def get_models():
return dict(
user=UserModel
return Object(
**{
"user": UserModel,
"channel_member": ChannelMemberModel,
"channel": ChannelModel,
"channel_message": ChannelMessageModel,
"drive_item": DriveItemModel,
"drive": DriveModel,
"notification": NotificationModel,
"user_property": UserPropertyModel,
"repository": RepositoryModel,
"channel_attachment": ChannelAttachmentModel,
"container": Container,
}
)
def get_model(name):
return get_models()[name]
+30
View File
@@ -0,0 +1,30 @@
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
class ChannelModel(BaseModel):
label = ModelField(name="label", required=True, kind=str)
description = ModelField(name="description", required=False, kind=str)
tag = ModelField(name="tag", required=False, kind=str)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_private = ModelField(name="is_private", required=True, kind=bool, value=False)
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
index = ModelField(name="index", required=True, kind=int, value=1000)
last_message_on = ModelField(name="last_message_on", required=False, kind=str)
async def get_last_message(self) -> ChannelMessageModel:
try:
async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]},
):
return await self.app.services.channel_message.get(uid=model["uid"])
except:
pass
return None
async def get_members(self):
return await self.app.services.channel_member.find(
channel_uid=self["uid"], deleted_at=None, is_banned=False
)
+16
View File
@@ -0,0 +1,16 @@
from snek.system.model import BaseModel
from snek.system.model import BaseModel, ModelField
class ChannelAttachmentModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
path = ModelField(name="path", required=True, kind=str)
size = ModelField(name="size", required=False, kind=int)
user_uid = ModelField(name="user_uid", required=True, kind=str)
mime_type = ModelField(name="type", required=True, kind=str)
relative_url = ModelField(name="relative_url", required=True, kind=str)
resource_type = ModelField(name="resource_type", required=True, kind=str,value="file")
+41
View File
@@ -0,0 +1,41 @@
from snek.system.model import BaseModel, ModelField
class ChannelMemberModel(BaseModel):
label = ModelField(name="label", required=True, kind=str)
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)
is_moderator = ModelField(
name="is_moderator", required=True, kind=bool, value=False
)
is_read_only = ModelField(
name="is_read_only", required=True, kind=bool, value=False
)
is_muted = ModelField(name="is_muted", required=True, kind=bool, value=False)
is_banned = ModelField(name="is_banned", required=True, kind=bool, value=False)
new_count = ModelField(name="new_count", required=False, kind=int, value=0)
async def get_user(self):
return await self.app.services.user.get(uid=self["user_uid"])
async def get_channel(self):
return await self.app.services.channel.get(uid=self["channel_uid"])
async def get_name(self):
channel = await self.get_channel()
if channel["tag"] == "dm":
user = await self.get_other_dm_user()
return user["nick"]
return channel["name"] or self["label"]
async def get_other_dm_user(self):
channel = await self.get_channel()
if channel["tag"] != "dm":
return None
async for model in self.app.services.channel_member.find(
channel_uid=channel["uid"]
):
if model["uid"] != self["uid"]:
return await self.app.services.user.get(uid=model["user_uid"])
return await self.get_user()
+19
View File
@@ -0,0 +1,19 @@
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
from datetime import datetime,timezone
class ChannelMessageModel(BaseModel):
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)
message = ModelField(name="message", required=True, kind=str)
html = ModelField(name="html", required=False, kind=str)
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
async def get_user(self) -> UserModel:
return await self.app.services.user.get(uid=self["user_uid"])
async def get_channel(self):
return await self.app.services.channel.get(uid=self["channel_uid"])
+10
View File
@@ -0,0 +1,10 @@
from snek.system.model import BaseModel, ModelField
class Container(BaseModel):
id = ModelField(name="id", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
status = ModelField(name="status", required=True, kind=str)
resources = ModelField(name="resources", required=False, kind=str)
user_uid = ModelField(name="user_uid", required=False, kind=str)
path = ModelField(name="path", required=False, kind=str)
readonly = ModelField(name="readonly", required=False, kind=bool, default=False)
+14
View File
@@ -0,0 +1,14 @@
from snek.system.model import BaseModel, ModelField
class DriveModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True)
name = ModelField(name="name", required=False, type=str)
@property
async def items(self):
async for drive_item in self.app.services.drive_item.find(
drive_uid=self["uid"]
):
yield drive_item
+21
View File
@@ -0,0 +1,21 @@
import mimetypes
from snek.system.model import BaseModel, ModelField
class DriveItemModel(BaseModel):
drive_uid = ModelField(name="drive_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
path = ModelField(name="path", required=True, kind=str)
file_type = ModelField(name="file_type", required=True, kind=str)
file_size = ModelField(name="file_size", required=True, kind=int)
is_available = ModelField(name="is_available", required=True, kind=bool, initial_value=True)
@property
def extension(self):
return self["name"].split(".")[-1]
@property
def mime_type(self):
mimetype, _ = mimetypes.guess_type(self["name"])
return mimetype
+9
View File
@@ -0,0 +1,9 @@
from snek.system.model import BaseModel, ModelField
class NotificationModel(BaseModel):
object_uid = ModelField(name="object_uid", required=True)
object_type = ModelField(name="object_type", required=True)
message = ModelField(name="message", required=True)
user_uid = ModelField(name="user_uid", required=True)
read_at = ModelField(name="is_read", required=True)
+14
View File
@@ -0,0 +1,14 @@
from snek.model.user import UserModel
from snek.system.model import BaseModel, ModelField
class RepositoryModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
is_private = ModelField(name="is_private", required=False, kind=bool)
+47 -6
View File
@@ -1,19 +1,60 @@
from snek.system.model import BaseModel,ModelField
from snek.system.model import BaseModel, ModelField
class UserModel(BaseModel):
username = ModelField(
name="username",
name="username",
required=True,
min_length=2,
max_length=20,
regex=r"^[a-zA-Z0-9_]+$",
regex=r"^[a-zA-Z0-9_-+/]+$",
)
nick = ModelField(
name="nick",
required=True,
min_length=2,
max_length=20,
regex=r"^[a-zA-Z0-9_-+/]+$",
)
color = ModelField(
name="color", required=True, regex=r"^#[0-9a-fA-F]{6}$", kind=str
)
email = ModelField(
name="email",
required=False,
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
)
password = ModelField(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}")
password = ModelField(name="password", required=True, min_length=1)
last_ping = ModelField(name="last_ping", required=False, kind=str)
is_admin = ModelField(name="is_admin", required=False, kind=bool)
async def get_property(self, name):
prop = await self.app.services.user_property.find_one(
user_uid=self["uid"], name=name
)
if prop:
return prop["value"]
async def has_property(self, name):
return await self.app.services.user_property.exists(
user_uid=self["uid"], name=name
)
async def set_property(self, name, value):
if not await self.has_property(name):
await self.app.services.user_property.insert(
user_uid=self["uid"], name=name, value=value
)
else:
await self.app.services.user_property.update(
user_uid=self["uid"], name=name, value=value
)
async def get_channel_members(self):
async for channel_member in self.app.services.channel_member.find(
user_uid=self["uid"], is_banned=False, deleted_at=None
):
yield channel_member
+7
View File
@@ -0,0 +1,7 @@
from snek.system.model import BaseModel, ModelField
class UserPropertyModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
name = ModelField(name="name", required=True, kind=str)
value = ModelField(name="path", required=True, kind=str)
+242
View File
@@ -0,0 +1,242 @@
import json
import asyncio
import aiohttp
from aiohttp import web
import dataset
import dataset.util
import traceback
import socket
import base64
import uuid
class DatasetMethod:
def __init__(self, dt, name):
self.dt = dt
self.name = name
def __call__(self, *args, **kwargs):
return self.dt.ds.call(
self.dt.name,
self.name,
*args,
**kwargs
)
class DatasetTable:
def __init__(self, ds, name):
self.ds = ds
self.name = name
def __getattr__(self, name):
return DatasetMethod(self, name)
class WebSocketClient2:
def __init__(self, uri):
self.uri = uri
self.loop = asyncio.get_event_loop()
self.websocket = None
self.receive_queue = asyncio.Queue()
# Schedule connection setup
if self.loop.is_running():
# Schedule connect in the existing loop
self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)
else:
# If loop isn't running, connect synchronously
self.loop.run_until_complete(self._connect())
async def _connect(self):
self.websocket = await websockets.connect(self.uri)
# Start listening for messages
asyncio.create_task(self._receive_loop())
async def _receive_loop(self):
try:
async for message in self.websocket:
await self.receive_queue.put(message)
except Exception:
pass # Handle exceptions as needed
def send(self, message: str):
if self.loop.is_running():
# Schedule send in the existing loop
asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
else:
# If loop isn't running, run directly
self.loop.run_until_complete(self.websocket.send(message))
def receive(self):
# Wait for a message synchronously
future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)
return future.result()
def close(self):
if self.websocket:
if self.loop.is_running():
asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)
else:
self.loop.run_until_complete(self.websocket.close())
import websockets
class DatasetWrapper(object):
def __init__(self):
self.ws = WebSocketClient()
def begin(self):
self.call(None, 'begin')
def commit(self):
self.call(None, 'commit')
def __getitem__(self, name):
return DatasetTable(self, name)
def query(self, *args, **kwargs):
return self.call(None, 'query', *args, **kwargs)
def call(self, table, method, *args, **kwargs):
payload = {"table": table, "method": method, "args": args, "kwargs": kwargs,"call_uid":None}
#if method in ['find','find_one']:
payload["call_uid"] = str(uuid.uuid4())
self.ws.write(json.dumps(payload))
if payload["call_uid"]:
response = self.ws.read()
return json.loads(response)['result']
return True
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
app = web.Application()
view = DatasetWebSocketView()
app.router.add_get('/db', view.handle)
async def run_server():
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', 3131)
await site.start()
print("Server started at http://localhost:8080")
await asyncio.Event().wait()
async def client():
print("x")
d = DatasetWrapper()
print("y")
for x in range(100):
for x in range(100):
if d['test'].insert({"name": "test", "number":x}):
print(".",end="",flush=True)
print("")
print(d['test'].find_one(name="test", order_by="-number"))
print("DONE")
import time
async def main():
await run_server()
import sys
if __name__ == '__main__':
if sys.argv[1] == 'server':
asyncio.run(main())
if sys.argv[1] == 'client':
asyncio.run(client())
+51
View File
@@ -0,0 +1,51 @@
import snek.serpentarium
import time
from concurrent.futures import ProcessPoolExecutor
durations = []
def task1():
global durations
client = snek.serpentarium.DatasetWrapper()
start=time.time()
for x in range(1500):
client['a'].delete()
client['a'].insert({"foo": x})
client['a'].find(foo=x)
client['a'].find_one(foo=x)
client['a'].count()
#print(client['a'].find(foo=x) )
#print(client['a'].find_one(foo=x) )
#print(client['a'].count())
client.close()
duration1 = f"{time.time()-start}"
durations.append(duration1)
print(durations)
with ProcessPoolExecutor(max_workers=4) as executor:
tasks = [executor.submit(task1),
executor.submit(task1),
executor.submit(task1),
executor.submit(task1)
]
for task in tasks:
task.result()
import dataset
client = dataset.connect("sqlite:///snek.db")
start=time.time()
for x in range(1500):
client['a'].delete()
client['a'].insert({"foo": x})
print([dict(row) for row in client['a'].find(foo=x)])
print(dict(client['a'].find_one(foo=x) ))
print(client['a'].count())
duration2 = f"{time.time()-start}"
print(duration1,duration2)
+103
View File
@@ -0,0 +1,103 @@
CREATE TABLE IF NOT EXISTS http_access (
id INTEGER NOT NULL,
created TEXT,
path TEXT,
duration FLOAT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL,
color TEXT,
created_at TEXT,
deleted_at TEXT,
email TEXT,
is_admin TEXT,
last_ping TEXT,
nick TEXT,
password TEXT,
uid TEXT,
updated_at TEXT,
username TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
CREATE TABLE IF NOT EXISTS channel (
id INTEGER NOT NULL,
created_at TEXT,
created_by_uid TEXT,
deleted_at TEXT,
description TEXT,
"index" BIGINT,
is_listed BOOLEAN,
is_private BOOLEAN,
label TEXT,
last_message_on TEXT,
tag TEXT,
uid TEXT,
updated_at TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
is_banned BOOLEAN,
is_moderator BOOLEAN,
is_muted BOOLEAN,
is_read_only BOOLEAN,
label TEXT,
new_count BIGINT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
CREATE TABLE IF NOT EXISTS broadcast (
id INTEGER NOT NULL,
channel_uid TEXT,
message TEXT,
created_at TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
html TEXT,
message TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
CREATE TABLE IF NOT EXISTS notification (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
message TEXT,
object_type TEXT,
object_uid TEXT,
read_at TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
CREATE TABLE IF NOT EXISTS repository (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
is_private BIGINT,
name TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
+54
View File
@@ -0,0 +1,54 @@
const channelUid = "{{ channel.uid.value }}";
function initInputField(textBox) {
textBox.addEventListener('change', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
});
textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (message) {
app.rpc.sendMessage(channelUid, message);
e.target.value = '';
}
}
});
textBox.focus();
}
function updateTimes() {
document.querySelectorAll(".time").forEach((time) => {
time.innerText = app.timeDescription(time.dataset.created_at);
});
}
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const messagesContainer = document.querySelector(".chat-messages");
let isLoadingExtra = false;
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
setInterval(updateTimes, 1000);
app.addEventListener("channel-message", (data) => {
if (data.channel_uid !== channelUid) {
if(!isMentionForSomeoneElse(data.message)){
channelSidebar.notify(data);
}
}
});
+39 -7
View File
@@ -1,12 +1,44 @@
from snek.service.user import UserService
import functools
import functools
from snek.service.channel import ChannelService
from snek.service.channel_member import ChannelMemberService
from snek.service.channel_message import ChannelMessageService
from snek.service.chat import ChatService
from snek.service.drive import DriveService
from snek.service.drive_item import DriveItemService
from snek.service.notification import NotificationService
from snek.service.socket import SocketService
from snek.service.user import UserService
from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.service.repository import RepositoryService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.container import ContainerService
from snek.system.object import Object
from snek.service.db import DBService
@functools.cache
def get_services(app):
return dict(
user = UserService(app=app)
return Object(
**{
"user": UserService(app=app),
"channel_member": ChannelMemberService(app=app),
"channel": ChannelService(app=app),
"channel_message": ChannelMessageService(app=app),
"chat": ChatService(app=app),
"socket": SocketService(app=app),
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app),
"user_property": UserPropertyService(app=app),
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
}
)
def get_service(name, app=None):
return get_services(app=app)[name]
return get_services(app=app)[name]
+121
View File
@@ -0,0 +1,121 @@
from datetime import datetime
from snek.system.model import now
from snek.system.service import BaseService
import pathlib
class ChannelService(BaseService):
mapper_name = "channel"
async def get_attachment_folder(self, channel_uid,ensure=False):
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
if ensure:
path.mkdir(
parents=True, exist_ok=True
)
return path
async def get(self, uid=None, **kwargs):
if uid:
kwargs["uid"] = uid
result = await super().get(**kwargs)
if result:
return result
del kwargs["uid"]
kwargs["name"] = uid
result = await super().get(**kwargs)
if result:
return result
kwargs["name"] = "#" + uid
result = await super().get(**kwargs)
if result:
return result
return None
return await super().get(**kwargs)
async def create(
self,
label,
created_by_uid,
description=None,
tag=None,
is_private=False,
is_listed=True,
):
if label[0] != "#" and is_listed:
label = f"#{label}"
count = await self.count(deleted_at=None)
if not tag and not count:
tag = "public"
model = await self.new()
model["label"] = label
model["description"] = description
model["tag"] = tag
model["created_by_uid"] = created_by_uid
model["is_private"] = is_private
model["is_listed"] = is_listed
if await self.save(model):
return model
raise Exception(f"Failed to create channel: {model.errors}.")
async def get_dm(self, user1, user2):
channel_member = await self.services.channel_member.get_dm(user1, user2)
if channel_member:
return await self.get(uid=channel_member["channel_uid"])
channel = await self.create("DM", user1, tag="dm")
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
return channel
async def get_recent_users(self, channel_uid):
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
yield user
async def get_users(self, channel_uid):
async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid,
is_banned=False,
is_muted=False,
deleted_at=None,
):
user = await self.services.user.get(uid=channel_member["user_uid"])
if user:
yield user
async def get_online_users(self, channel_uid):
async for user in self.get_users(channel_uid):
if not user["last_ping"]:
continue
if (
datetime.fromisoformat(now())
- datetime.fromisoformat(user["last_ping"])
).total_seconds() < 180:
yield user
async def get_for_user(self, user_uid):
async for channel_member in self.services.channel_member.find(
user_uid=user_uid,
is_banned=False,
deleted_at=None,
):
channel = await self.get(uid=channel_member["channel_uid"])
yield channel
async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public")
is_moderator = False
if not model:
is_moderator = True
model = await self.create(
"public", created_by_uid=created_by_uid, is_listed=True, tag="public"
)
await self.app.services.channel_member.create(
model["uid"],
created_by_uid,
is_moderator=is_moderator,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return model
+25
View File
@@ -0,0 +1,25 @@
from snek.system.service import BaseService
import urllib.parse
import pathlib
import mimetypes
import uuid
class ChannelAttachmentService(BaseService):
mapper_name="channel_attachment"
async def create_file(self, channel_uid, user_uid, name):
attachment = await self.new()
attachment["channel_uid"] = channel_uid
attachment['user_uid'] = user_uid
attachment["name"] = name
attachment["mime_type"] = mimetypes.guess_type(name)[0]
attachment['resource_type'] = "file"
real_file_name = f"{attachment['uid']}-{name}"
attachment["relative_url"] = (f"{attachment['uid']}-{name}")
attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)
attachment_path = attachment_folder.joinpath(real_file_name)
attachment["path"] = str(attachment_path)
if await self.save(attachment):
return attachment
raise Exception(f"Failed to create channel attachment: {attachment.errors}.")
+74
View File
@@ -0,0 +1,74 @@
from snek.system.service import BaseService
class ChannelMemberService(BaseService):
mapper_name = "channel_member"
async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0
return await self.save(channel_member)
async def get_user_uids(self, channel_uid):
async for model in self.mapper.query(
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
{"channel_uid": channel_uid},
):
yield model["user_uid"]
async def create(
self,
channel_uid,
user_uid,
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
):
model = await self.get(channel_uid=channel_uid, user_uid=user_uid)
if model:
if model["is_banned"]:
return False
return model
model = await self.new()
channel = await self.services.channel.get(uid=channel_uid)
model["label"] = channel["label"]
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["is_moderator"] = is_moderator
model["is_read_only"] = is_read_only
model["is_muted"] = is_muted
model["is_banned"] = is_banned
if await self.save(model):
return model
raise Exception(f"Failed to create channel member: {model.errors}.")
async def get_dm(self, from_user, to_user):
async for model in self.query(
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
if not from_user == to_user:
return None
async for model in self.query(
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
{"from_user": from_user, "to_user": to_user},
):
return model
async def get_other_dm_user(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel = await self.services.channel.get(uid=channel_member["channel_uid"])
if channel["tag"] != "dm":
return None
async for model in self.services.channel_member.find(channel_uid=channel_uid):
if model["uid"] != channel_member["uid"]:
return await self.services.user.get(uid=model["user_uid"])
async def create_dm(self, channel_uid, from_user_uid, to_user_uid):
result = await self.create(channel_uid, from_user_uid)
await self.create(channel_uid, to_user_uid)
return result
+110
View File
@@ -0,0 +1,110 @@
from snek.system.service import BaseService
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model['is_final'] = is_final
context = {}
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
try:
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
except Exception as ex:
print(ex, flush=True)
if await super().save(model):
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
async def to_extended_dict(self, message):
user = await self.services.user.get(uid=message["user_uid"])
if not user:
return {}
return {
"uid": message["uid"],
"color": user["color"],
"user_uid": message["user_uid"],
"channel_uid": message["channel_uid"],
"user_nick": user["nick"],
"message": message["message"],
"created_at": message["created_at"],
"html": message["html"],
"username": user["username"],
}
async def save(self, model):
context = {}
context.update(model.record)
user = await self.app.services.user.get(model['user_uid'])
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
results = []
offset = page * page_size
try:
if timestamp:
async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
"timestamp": timestamp,
},
):
results.append(model)
elif page > 0:
async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
"timestamp": timestamp,
},
):
results.append(model)
else:
async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
{
"channel_uid": channel_uid,
"page_size": page_size,
"offset": offset,
},
):
results.append(model)
except:
pass
results.sort(key=lambda x: x["created_at"])
return results
+66
View File
@@ -0,0 +1,66 @@
from snek.system.model import now
from snek.system.service import BaseService
class ChatService(BaseService):
async def finalize(self, message_uid):
channel_message = await self.services.channel_message.get(uid=message_uid)
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel['uid'],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user['uid'],
"color": user["color"],
"channel_uid": channel['uid'],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": channel_message["is_final"],
},
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message,is_final
)
channel_message_uid = channel_message["uid"]
user = await self.services.user.get(uid=user_uid)
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel_uid,
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user_uid,
"color": user["color"],
"channel_uid": channel_uid,
"created_at": channel_message["created_at"],
"updated_at": None,
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": is_final,
},
)
await self.app.create_task(
self.services.notification.create_channel_message(channel_message_uid)
)
return channel_message
+29
View File
@@ -0,0 +1,29 @@
from snek.system.service import BaseService
class ContainerService(BaseService):
mapper_name = "container"
async def create(self, id, name, status, resources=None, user_uid=None, path=None, readonly=False):
model = await self.new()
model["id"] = id
model["name"] = name
model["status"] = status
if resources:
model["resources"] = resources
if user_uid:
model["user_uid"] = user_uid
if path:
model["path"] = path
model["readonly"] = readonly
if await super().save(model):
return model
raise Exception(f"Failed to create container: {model.errors}")
async def get(self, id):
return await self.mapper.get(id)
async def update(self, model):
return await self.mapper.update(model)
async def delete(self, id):
return await self.mapper.delete(id)
+71
View File
@@ -0,0 +1,71 @@
from snek.system.service import BaseService
import dataset
import uuid
from datetime import datetime
class DBService(BaseService):
async def get_db(self, user_uid):
home_folder = await self.app.services.user.get_home_folder(user_uid)
home_folder.mkdir(parents=True, exist_ok=True)
db_path = home_folder.joinpath("snek/user.db")
db_path.parent.mkdir(parents=True, exist_ok=True)
return dataset.connect("sqlite:///" + str(db_path))
async def insert(self, user_uid, table_name, values):
db = await self.get_db(user_uid)
return db[table_name].insert(values)
async def update(self, user_uid, table_name, values, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
if not values:
return False
return db[table_name].update(values, filters)
async def upsert(self, user_uid, table_name, values, keys):
db = await self.get_db(user_uid)
return db[table_name].upsert(values, keys)
async def find(self, user_uid, table_name, kwargs):
db = await self.get_db(user_uid)
kwargs['_limit'] = kwargs.get('_limit', 30)
return [dict(row) for row in db[table_name].find(**kwargs)]
async def get(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
try:
return dict(db[table_name].find_one(**filters))
except ValueError:
return None
async def delete(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return db[table_name].delete(**filters)
async def query(self, sql,values):
db = await self.app.db
return [dict(row) for row in db.query(sql, values or {})]
async def exists(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return bool(db[table_name].find_one(**filters))
async def count(self, user_uid, table_name, filters):
db = await self.get_db(user_uid)
if not filters:
filters = {}
return db[table_name].count(**filters)
+153
View File
@@ -0,0 +1,153 @@
from snek.system.service import BaseService
class DriveService(BaseService):
mapper_name = "drive"
EXTENSIONS_PICTURES = ["jpg", "jpeg", "png", "gif", "svg", "webp", "tiff"]
EXTENSIONS_VIDEOS = [
"mp4",
"m4v",
"mov",
"wmv",
"webm",
"mkv",
"mpg",
"mpeg",
"avi",
"ogv",
"ogg",
"flv",
"3gp",
"3g2",
]
EXTENSIONS_ARCHIVES = [
"zip",
"rar",
"7z",
"tar",
"tar.gz",
"tar.xz",
"tar.bz2",
"tar.lzma",
"tar.lz",
]
EXTENSIONS_AUDIO = [
"mp3",
"wav",
"ogg",
"flac",
"m4a",
"wma",
"aac",
"opus",
"aiff",
"au",
"mid",
"midi",
]
EXTENSIONS_DOCS = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"txt",
"md",
"json",
"csv",
"xml",
"html",
"css",
"js",
"py",
"sql",
"rs",
"toml",
"yml",
"yaml",
"ini",
"conf",
"config",
"log",
"csv",
"tsv",
"java",
"cs",
"csproj",
"scss",
"less",
"sass",
"json",
"lock",
"lock.json",
"jsonl",
]
async def get_drive_name_by_extension(self, extension):
if extension.startswith("."):
extension = extension[1:]
if extension in self.EXTENSIONS_PICTURES:
return "Pictures"
if extension in self.EXTENSIONS_VIDEOS:
return "Videos"
if extension in self.EXTENSIONS_ARCHIVES:
return "Archives"
if extension in self.EXTENSIONS_AUDIO:
return "Audio"
if extension in self.EXTENSIONS_DOCS:
return "Documents"
return "My Drive"
async def get_drive_by_extension(self, user_uid, extension):
name = await self.get_drive_name_by_extension(extension)
return await self.get_or_create(user_uid=user_uid, name=name)
async def get_by_user(self, user_uid, name=None):
kwargs = {"user_uid": user_uid}
async for model in self.find(**kwargs):
if not name:
yield model
elif model["name"] == name:
yield model
elif not model["name"] and name == "My Drive":
model["name"] = "My Drive"
await self.save(model)
yield model
async def get_or_create(self, user_uid, name=None, extensions=None):
kwargs = {"user_uid": user_uid}
if name:
kwargs["name"] = name
async for model in self.get_by_user(**kwargs):
return model
model = await self.new()
model["user_uid"] = user_uid
model["name"] = name
await self.save(model)
return model
async def prepare_default_drives(self):
async for drive_item in self.services.drive_item.find():
extension = drive_item.extension
drive = await self.get_drive_by_extension(drive_item["user_uid"], extension)
if not drive_item["drive_uid"] == drive["uid"]:
drive_item["drive_uid"] = drive["uid"]
await self.services.drive_item.save(drive_item)
async def prepare_default_drives_for_user(self, user_uid):
await self.get_or_create(user_uid=user_uid, name="My Drive")
await self.get_or_create(user_uid=user_uid, name="Shared Drive")
await self.get_or_create(user_uid=user_uid, name="Pictures")
await self.get_or_create(user_uid=user_uid, name="Videos")
await self.get_or_create(user_uid=user_uid, name="Archives")
await self.get_or_create(user_uid=user_uid, name="Documents")
async def prepare_all(self):
await self.prepare_default_drives()
async for user in self.services.user.find():
await self.prepare_default_drives_for_user(user["uid"])
+20
View File
@@ -0,0 +1,20 @@
from snek.system.service import BaseService
class DriveItemService(BaseService):
mapper_name = "drive_item"
async def create(self, drive_uid, name, path, type_, size):
model = await self.new()
model["drive_uid"] = drive_uid
model["name"] = name
model["path"] = str(path)
model["extension"] = str(name).split(".")[-1]
model["file_type"] = type_
model["file_size"] = size
if await self.save(model):
return model
errors = await model.errors
print("XXXXXXXXXX")
raise Exception(f"Failed to create drive item: {errors}.")
+65
View File
@@ -0,0 +1,65 @@
from snek.system.model import now
from snek.system.service import BaseService
class NotificationService(BaseService):
mapper_name = "notification"
async def mark_as_read(self, user_uid, channel_message_uid):
model = await self.get(user_uid, object_uid=channel_message_uid)
if not model:
return False
model["read_at"] = now()
await self.save(model)
return True
async def get_unread_stats(self, user_uid):
await self.query(
"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type",
{"user_uid": user_uid},
)
async def create(self, object_uid, object_type, user_uid, message):
model = await self.new()
model["object_uid"] = object_uid
model["object_type"] = object_type
model["user_uid"] = user_uid
model["message"] = message
if await self.save(model):
return model
raise Exception(f"Failed to create notification: {model.errors}.")
async def create_channel_message(self, channel_message_uid):
channel_message = await self.services.channel_message.get(
uid=channel_message_uid
)
user = await self.services.user.get(uid=channel_message["user_uid"])
self.app.db.begin()
async for channel_member in self.services.channel_member.find(
channel_uid=channel_message["channel_uid"],
is_banned=False,
is_muted=False,
deleted_at=None,
):
if not channel_member["new_count"]:
channel_member["new_count"] = 0
channel_member["new_count"] += 1
usr = await self.services.user.get(uid=channel_member["user_uid"])
if not usr:
continue
await self.services.channel_member.save(channel_member)
model = await self.new()
model["object_uid"] = channel_message_uid
model["object_type"] = "channel_message"
model["user_uid"] = channel_member["user_uid"]
model["message"] = (
f"New message from {user['nick']} in {channel_member['label']}."
)
try:
await self.save(model)
except Exception:
raise Exception(f"Failed to create notification: {model.errors}.")
self.app.db.commit()
+52
View File
@@ -0,0 +1,52 @@
from snek.system.service import BaseService
import asyncio
import shutil
class RepositoryService(BaseService):
mapper_name = "repository"
async def delete(self, user_uid, name):
loop = asyncio.get_event_loop()
repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
print(ex)
await super().delete(user_uid=user_uid, name=name)
async def exists(self, user_uid, name, **kwargs):
kwargs["user_uid"] = user_uid
kwargs["name"] = name
return await super().exists(**kwargs)
async def init(self, user_uid, name):
repository_path = await self.services.user.get_repository_path(user_uid)
if not repository_path.exists():
repository_path.mkdir(parents=True)
repository_path = repository_path.joinpath(name)
repository_path = str(repository_path)
if not repository_path.endswith(".git"):
repository_path += ".git"
command = ['git', 'init', '--bare', repository_path]
process = await asyncio.subprocess.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
return process.returncode == 0
async def create(self, user_uid, name,is_private=False):
if await self.exists(user_uid=user_uid, name=name):
return False
if not await self.init(user_uid=user_uid, name=name):
return False
model = await self.new()
model["user_uid"] = user_uid
model["name"] = name
model["is_private"] = is_private
return await self.save(model)
+105
View File
@@ -0,0 +1,105 @@
from snek.model.user import UserModel
from snek.system.service import BaseService
from datetime import datetime
import json
import asyncio
import logging
logger = logging.getLogger(__name__)
from snek.system.model import now
class SocketService(BaseService):
class Socket:
def __init__(self, ws, user: UserModel):
self.ws = ws
self.is_connected = True
self.user = user
async def send_json(self, data):
if not self.is_connected:
return False
try:
await self.ws.send_json(data)
except Exception:
self.is_connected = False
return self.is_connected
async def close(self):
if not self.is_connected:
return True
await self.ws.close()
self.is_connected = False
return True
def __init__(self, app):
super().__init__(app)
self.sockets = set()
self.users = {}
self.subscriptions = {}
self.last_update = str(datetime.now())
async def user_availability_service(self):
logger.info("User availability update service started.")
while True:
logger.info("Updating user availability...")
users_updated = []
for s in self.sockets:
if not s.is_connected:
continue
if not s.user in users_updated:
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
users_updated.append(s.user)
logger.info(f"Updated user availability for {len(users_updated)} online users.")
await asyncio.sleep(60)
async def add(self, ws, user_uid):
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.sockets.add(s)
s.user["last_ping"] = now()
await self.app.services.user.save(s.user)
logger.info(f"Added socket for user {s.user['username']}")
if not self.users.get(user_uid):
self.users[user_uid] = set()
self.users[user_uid].add(s)
async def subscribe(self, ws, channel_uid, user_uid):
if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set()
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
self.subscriptions[channel_uid].add(s)
async def send_to_user(self, user_uid, message):
count = 0
for s in self.users.get(user_uid, []):
if await s.send_json(message):
count += 1
return count
async def broadcast(self, channel_uid, message):
await self._broadcast(channel_uid, message)
async def _broadcast(self, channel_uid, message):
sent = 0
try:
async for user_uid in self.services.channel_member.get_user_uids(
channel_uid
):
sent += await self.send_to_user(user_uid, message)
except Exception as ex:
print(ex, flush=True)
logger.info(f"Broadcasted a message to {sent} users.")
return True
async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close()
logger.info(f"Removed socket for user {s.user['username']}")
self.sockets.remove(s)
+102 -7
View File
@@ -1,17 +1,112 @@
from snek.system.service import BaseService
from snek.system import security
import pathlib
from snek.system import security
from snek.system.service import BaseService
class UserService(BaseService):
mapper_name = "user"
async def get_by_username(self, username):
return await self.get(username=username)
async def search(self, query, **kwargs):
query = query.strip().lower()
if not query:
return []
results = []
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
return results
async def validate_login(self, username, password):
model = await self.get(username=username)
if not model:
return False
if not await security.verify(password, model["password"]):
return False
return True
async def save(self, user):
if not user["color"]:
user["color"] = await self.services.util.random_light_hex_color()
return await super().save(user)
def authenticate_sync(self,username,password):
user = self.get_by_username_sync(username)
if not user:
return False
if not security.verify_sync(password, user["password"]):
return False
return True
async def authenticate(self, username, password):
success = await self.validate_login(username, password)
if not success:
return None
model = await self.get(username=username, deleted_at=None)
return model
def get_admin_uids(self):
return self.mapper.get_admin_uids()
async def get_repository_path(self, user_uid):
return pathlib.Path(f"./drive/repositories/{user_uid}")
async def get_static_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/static")
if not path.exists():
return None
return path
async def get_template_path(self, user_uid):
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
if not path.exists():
return None
return path
def get_by_username_sync(self, username):
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
return dict(user)
def get_home_folder_by_username(self, username):
user = self.get_by_username_sync(username)
folder = pathlib.Path(f"./drive/{user['uid']}")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
pass
return folder
async def get_home_folder(self, user_uid):
folder = pathlib.Path(f"./drive/{user_uid}")
if not folder.exists():
try:
folder.mkdir(parents=True, exist_ok=True)
except:
pass
return folder
async def register(self, email, username, password):
if await self.exists(username=username):
raise Exception("User already exists.")
model = await self.new()
model.email = email
model.username = username
model.password = await security.hash(password)
model["nick"] = username
model["color"] = await self.services.util.random_light_hex_color()
model.email.value = email
model.username.value = username
model.password.value = await security.hash(password)
if await self.save(model):
return model
if model:
channel = await self.services.channel.ensure_public_channel(
model["uid"]
)
if not channel:
raise Exception("Failed to create public channel.")
return model
raise Exception(f"Failed to create user: {model.errors}.")
+35
View File
@@ -0,0 +1,35 @@
import json
from snek.system.service import BaseService
class UserPropertyService(BaseService):
mapper_name = "user_property"
async def set(self, user_uid, name, value):
self.mapper.db["user_property"].upsert(
{
"user_uid": user_uid,
"name": name,
"value": json.dumps(value, default=str),
},
["user_uid", "name"],
)
async def get(self, user_uid, name):
try:
return json.loads(
(await super().get(user_uid=user_uid, name=name))["value"]
)
except Exception as ex:
print(ex)
return None
async def search(self, query, **kwargs):
query = query.strip().lower()
if not query:
raise []
results = []
async for result in self.find(name={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
return results
+14
View File
@@ -0,0 +1,14 @@
import random
from snek.system.service import BaseService
class UtilService(BaseService):
async def random_light_hex_color(self):
r = random.randint(128, 255)
g = random.randint(128, 255)
b = random.randint(128, 255)
return f"#{r:02x}{g:02x}{b:02x}"
+486
View File
@@ -0,0 +1,486 @@
import os
import aiohttp
from aiohttp import web
import shutil
import json
import tempfile
import asyncio
import logging
import base64
import pathlib
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('git_server')
class GitApplication(web.Application):
def __init__(self, parent=None):
#import git
#globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024*1024*1024*5)
self.add_routes([
web.post('/create/{repo_name}', self.create_repository),
web.delete('/delete/{repo_name}', self.delete_repository),
web.get('/clone/{repo_name}', self.clone_repository),
web.post('/push/{repo_name}', self.push_repository),
web.post('/pull/{repo_name}', self.pull_repository),
web.get('/status/{repo_name}', self.status_repository),
#web.get('/list', self.list_repositories),
web.get('/branches/{repo_name}', self.list_branches),
web.post('/branches/{repo_name}', self.create_branch),
web.get('/log/{repo_name}', self.commit_log),
web.get('/file/{repo_name}/{file_path:.*}', self.file_content),
web.get('/{path:.+}/info/refs', self.git_smart_http),
web.post('/{path:.+}/git-upload-pack', self.git_smart_http),
web.post('/{path:.+}/git-receive-pack', self.git_smart_http),
web.get('/{repo_name}.git/info/refs', self.git_smart_http),
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),
web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),
])
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None,None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
request["user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
if not request["user"]:
return None,None
request["repository_path"] = await self.parent.services.user.get_repository_path(
request["user"]["uid"]
)
return request["user"]['username'],request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
username, repository_path = await self.check_basic_auth(request)
if not username or not repository_path:
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')
request['username'] = username
request['repository_path'] = repository_path
return await handler(self, request, *args, **kwargs)
return wrapped
def repo_path(self, repository_path, repo_name):
return repository_path.joinpath(repo_name + '.git')
def check_repo_exists(self, repository_path, repo_name):
repo_dir = self.repo_path(repository_path, repo_name)
if not os.path.exists(repo_dir):
return web.Response(text="Repository not found", status=404)
return None
@require_auth
async def create_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
if not repo_name or '/' in repo_name or '..' in repo_name:
return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name)
if os.path.exists(repo_dir):
return web.Response(text="Repository already exists", status=400)
try:
git.Repo.init(repo_dir, bare=True)
logger.info(f"Created repository: {repo_name} for user {username}")
return web.Response(text=f"Created repository {repo_name}")
except Exception as e:
logger.error(f"Error creating repository {repo_name}: {str(e)}")
return web.Response(text=f"Error creating repository: {str(e)}", status=500)
@require_auth
async def delete_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
#'''
try:
shutil.rmtree(self.repo_path(repository_path, repo_name))
logger.info(f"Deleted repository: {repo_name} for user {username}")
return web.Response(text=f"Deleted repository {repo_name}")
except Exception as e:
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
return web.Response(text=f"Error deleting repository: {str(e)}", status=500)
@require_auth
async def clone_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
host = request.host
clone_url = f"http://{host}/{repo_name}.git"
response_data = {
"repository": repo_name,
"clone_command": f"git clone {clone_url}",
"clone_url": clone_url
}
return web.json_response(response_data)
@require_auth
async def push_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
try:
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
commit_message = data.get('commit_message', 'Update from server')
branch = data.get('branch', 'main')
changes = data.get('changes', [])
if not changes:
return web.Response(text="No changes provided", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
for change in changes:
file_path = os.path.join(temp_dir, change.get('file', ''))
content = change.get('content', '')
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
f.write(content)
temp_repo.git.add(A=True)
if not temp_repo.config_reader().has_section('user'):
temp_repo.config_writer().set_value("user", "name", "Git Server").release()
temp_repo.config_writer().set_value("user", "email", "git@server.local").release()
temp_repo.index.commit(commit_message)
origin = temp_repo.remote('origin')
origin.push(refspec=f"{branch}:{branch}")
logger.info(f"Pushed to repository: {repo_name} for user {username}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
try:
data = await request.json()
except json.JSONDecodeError:
data = {}
remote_url = data.get('remote_url')
branch = data.get('branch', 'main')
if not remote_url:
return web.Response(text="Remote URL is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
remote_name = "pull_source"
try:
remote = local_repo.create_remote(remote_name, remote_url)
except git.GitCommandError:
remote = local_repo.remote(remote_name)
remote.set_url(remote_url)
remote.fetch()
local_repo.git.merge(f"{remote_name}/{branch}")
origin = local_repo.remote('origin')
origin.push()
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}")
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}")
except Exception as e:
logger.error(f"Error pulling to {repo_name}: {str(e)}")
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
@require_auth
async def status_repository(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
branches = [b.name for b in temp_repo.branches]
active_branch = temp_repo.active_branch.name
commits = []
for commit in list(temp_repo.iter_commits(max_count=5)):
commits.append({
"id": commit.hexsha,
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message
})
files = []
for root, dirs, filenames in os.walk(temp_dir):
if '.git' in root:
continue
for filename in filenames:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, temp_dir)
files.append(rel_path)
status_info = {
"repository": repo_name,
"branches": branches,
"active_branch": active_branch,
"recent_commits": commits,
"files": files
}
return web.json_response(status_info)
except Exception as e:
logger.error(f"Error getting status for {repo_name}: {str(e)}")
return web.Response(text=f"Error getting repository status: {str(e)}", status=500)
@require_auth
async def list_repositories(self, request):
username = request['username']
try:
repos = []
user_dir = self.REPO_DIR
if os.path.exists(user_dir):
for item in os.listdir(user_dir):
item_path = os.path.join(user_dir, item)
if os.path.isdir(item_path) and item.endswith('.git'):
repos.append(item[:-4])
if request.query.get('format') == 'json':
return web.json_response({"repositories": repos})
else:
return web.Response(text="\n".join(repos) if repos else "No repositories found")
except Exception as e:
logger.error(f"Error listing repositories: {str(e)}")
return web.Response(text=f"Error listing repositories: {str(e)}", status=500)
@require_auth
async def list_branches(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
branches = [b.name for b in temp_repo.branches]
return web.json_response({"branches": branches})
@require_auth
async def create_branch(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
try:
data = await request.json()
except json.JSONDecodeError:
return web.Response(text="Invalid JSON data", status=400)
branch_name = data.get('branch_name')
start_point = data.get('start_point', 'HEAD')
if not branch_name:
return web.Response(text="Branch name is required", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push('origin', branch_name)
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}")
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
logger.error(f"Error creating branch {branch_name} in {repo_name}: {str(e)}")
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
@require_auth
async def commit_log(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
try:
limit = int(request.query.get('limit', 10))
branch = request.query.get('branch', 'main')
except ValueError:
return web.Response(text="Invalid limit parameter", status=400)
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
commits = []
try:
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
commits.append({
"id": commit.hexsha,
"short_id": commit.hexsha[:7],
"author": f"{commit.author.name} <{commit.author.email}>",
"date": commit.committed_datetime.isoformat(),
"message": commit.message.strip()
})
except git.GitCommandError as e:
if "unknown revision or path" in str(e):
commits = []
else:
raise
return web.json_response({
"repository": repo_name,
"branch": branch,
"commits": commits
})
except Exception as e:
logger.error(f"Error getting commit log for {repo_name}: {str(e)}")
return web.Response(text=f"Error getting commit log: {str(e)}", status=500)
@require_auth
async def file_content(self, request):
username = request['username']
repo_name = request.match_info['repo_name']
file_path = request.match_info.get('file_path', '')
branch = request.query.get('branch', 'main')
repository_path = request['repository_path']
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
with tempfile.TemporaryDirectory() as temp_dir:
try:
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
try:
temp_repo.git.checkout(branch)
except git.GitCommandError:
return web.Response(text=f"Branch '{branch}' not found", status=404)
file_full_path = os.path.join(temp_dir, file_path)
if not os.path.exists(file_full_path):
return web.Response(text=f"File '{file_path}' not found", status=404)
if os.path.isdir(file_full_path):
files = os.listdir(file_full_path)
return web.json_response({
"repository": repo_name,
"path": file_path,
"type": "directory",
"contents": files
})
else:
try:
with open(file_full_path, 'r') as f:
content = f.read()
return web.Response(text=content)
except UnicodeDecodeError:
return web.Response(text=f"Cannot display binary file content for '{file_path}'", status=400)
except Exception as e:
logger.error(f"Error getting file content from {repo_name}: {str(e)}")
return web.Response(text=f"Error getting file content: {str(e)}", status=500)
@require_auth
async def git_smart_http(self, request):
username = request['username']
repository_path = request['repository_path']
path = request.path
async def get_repository_path():
req_path = path.lstrip('/')
if req_path.endswith('/info/refs'):
repo_name = req_path[:-len('/info/refs')]
elif req_path.endswith('/git-upload-pack'):
repo_name = req_path[:-len('/git-upload-pack')]
elif req_path.endswith('/git-receive-pack'):
repo_name = req_path[:-len('/git-receive-pack')]
else:
repo_name = req_path
if repo_name.endswith('.git'):
repo_name = repo_name[:-4]
repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
logger.info(f"Resolved repo path: {repo_dir}")
return repo_dir
async def handle_info_refs(service):
repo_path = await get_repository_path()
logger.info(f"handle_info_refs: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': f'application/x-{service}-advertisement',
'Cache-Control': 'no-cache'
}
)
await response.prepare(request)
packet = f"# service={service}\n"
length = len(packet) + 4
header = f"{length:04x}"
await response.write(f"{header}{packet}0000".encode())
await response.write(stdout)
return response
except Exception as e:
logger.error(f"Error handling info/refs: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
async def handle_service_rpc(service):
repo_path = await get_repository_path()
logger.info(f"handle_service_rpc: {repo_path}")
if not os.path.exists(repo_path):
return web.Response(text="Repository not found", status=404)
if not request.headers.get('Content-Type') == f'application/x-{service}-request':
return web.Response(text="Invalid Content-Type", status=403)
body = await request.read()
cmd = [service, '--stateless-rpc', str(repo_path)]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate(input=body)
if process.returncode != 0:
logger.error(f"Git command failed: {stderr.decode()}")
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
return web.Response(
body=stdout,
content_type=f'application/x-{service}-result'
)
except Exception as e:
logger.error(f"Error handling service RPC: {str(e)}")
return web.Response(text=f"Server error: {str(e)}", status=500)
if request.method == 'GET' and path.endswith('/info/refs'):
service = request.query.get('service')
if service in ('git-upload-pack', 'git-receive-pack'):
return await handle_info_refs(service)
else:
return web.Response(text="Smart HTTP requires service parameter", status=400)
elif request.method == 'POST' and '/git-upload-pack' in path:
return await handle_service_rpc('git-upload-pack')
elif request.method == 'POST' and '/git-receive-pack' in path:
return await handle_service_rpc('git-receive-pack')
return web.Response(text="Not found", status=404)
if __name__ == '__main__':
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logger.info("Using uvloop for improved performance")
except ImportError:
logger.info("uvloop not available, using standard event loop")
app = GitApplication()
logger.info("Starting Git server on port 8080")
web.run_app(app, port=8080)
+116
View File
@@ -0,0 +1,116 @@
import aiohttp
ENABLED = False
import aiohttp
import asyncio
from aiohttp import web
import sqlite3
import dataset
from sqlalchemy import event
from sqlalchemy.engine import Engine
import json
queue = asyncio.Queue()
class State:
do_not_sync = False
async def sync_service(app):
if not ENABLED:
return
session = aiohttp.ClientSession()
async with session.ws_connect('http://localhost:3131/ws') as ws:
async def receive():
queries_synced = 0
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
State.do_not_sync = True
app.db.execute(*data)
app.db.commit()
State.do_not_sync = False
queries_synced += 1
print("queries synced: " + str(queries_synced))
print(*data)
await app.services.socket.broadcast_event()
except Exception as e:
print(e)
pass
#print(f"Received: {msg.data}")
elif msg.type == aiohttp.WSMsgType.ERROR:
break
async def write():
while True:
msg = await queue.get()
await ws.send_str(json.dumps(msg,default=str))
queue.task_done()
await asyncio.gather(receive(), write())
await session.close()
queries_queued = 0
# Attach a listener to log all executed statements
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
if not ENABLED:
return
global queries_queued
if State.do_not_sync:
print(statement,parameters)
return
if statement.startswith("SELECT"):
return
queue.put_nowait((statement, parameters))
queries_queued += 1
print("Queries queued: " + str(queries_queued))
async def websocket_handler(request):
queries_broadcasted = 0
ws = web.WebSocketResponse()
await ws.prepare(request)
request.app['websockets'].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
for client in request.app['websockets']:
if client != ws:
await client.send_str(msg.data)
cursor = request.app['db'].cursor()
data = json.loads(msg.data)
queries_broadcasted += 1
cursor.execute(*data)
cursor.close()
print("Queries broadcasted: " + str(queries_broadcasted))
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f'WebSocket connection closed with exception {ws.exception()}')
request.app['websockets'].remove(ws)
return ws
app = web.Application()
app['websockets'] = []
app.router.add_get('/ws', websocket_handler)
async def on_startup(app):
app['db'] = sqlite3.connect('snek.db')
print("Server starting...")
async def on_cleanup(app):
for ws in app['websockets']:
await ws.close()
app['db'].close()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
if __name__ == '__main__':
web.run_app(app, host='127.0.0.1', port=3131)
+76
View File
@@ -0,0 +1,76 @@
import asyncssh
import logging
from pathlib import Path
global _app
def set_app(app):
global _app
_app = app
def get_app():
return _app
logger = logging.getLogger(__name__)
roots = {}
class SFTPServer(asyncssh.SFTPServer):
def __init__(self, chan: asyncssh.SSHServerChannel):
self.root = get_app().services.user.get_home_folder_by_username(
chan.get_extra_info('username')
)
self.root.mkdir(exist_ok=True)
self.root = str(self.root)
super().__init__(chan, chroot=self.root)
def map_path(self, path):
mapped_path = Path(self.root).joinpath(path.lstrip(b"/").decode())
logger.debug(f"Mapping client path {path} to {mapped_path}")
return str(mapped_path).encode()
class SSHServer(asyncssh.SSHServer):
def password_auth_supported(self):
return True
def validate_password(self, username, password):
logger.debug(f"Validating credentials for user {username}")
result = get_app().services.user.authenticate_sync(username,password)
logger.info(f"Validating credentials for user {username}: {result}")
return result
async def start_ssh_server(app,host,port):
set_app(app)
logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
host_key_path.parent.mkdir(exist_ok=True, parents=True)
try:
if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}")
key = asyncssh.generate_private_key("ecdsa-sha2-nistp256")
key.write_private_key(host_key_path)
else:
logger.info(f"Loading existing host key from {host_key_path}")
key = asyncssh.read_private_key(host_key_path)
except Exception as e:
logger.error(f"Failed to generate or load host key: {e}")
raise
logger.info(f"Starting SFTP server on 127.0.0.1:{port}")
try:
x = await asyncssh.listen(
host=host,
port=port,
#process_factory=handle_client,
server_host_keys=[key],
server_factory=SSHServer,
sftp_factory=SFTPServer
)
return x
except Exception as e:
logger.warning(f"Failed to start SFTP server. Already running.")
pass
+259 -81
View File
@@ -1,90 +1,268 @@
// Written by retoor@molodetz.nl
// This project implements a client-server communication system using WebSockets and REST APIs.
// It features a chat system, a notification sound system, and interaction with server endpoints.
class Message {
uid = null
author = null
avatar = null
text = null
time = null
constructor(uid,avatar,author,text,time){
this.uid = uid
this.avatar = avatar
this.author = author
this.text = text
this.time = time
// No additional imports were used beyond standard JavaScript objects and constructors.
// MIT License
import { Schedule } from "./schedule.js";
import { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js";
export class RESTClient {
debug = false;
async get(url, params = {}) {
const encodedParams = new URLSearchParams(params);
if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (this.debug) {
console.debug({ url, params, result });
}
get links() {
if(!this.text)
return []
let result = []
for(let part in this.text.split(/[,; ]/)){
if(part.startsWith("http") || part.startsWith("www.") || part.indexOf(".com") || part.indexOf(".net") || part.indexOf(".io") || part.indexOf(".nl")){
result.push(part)
return result;
}
}
async post(url, data) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (this.debug) {
console.debug({ url, data, result });
}
return result;
}
}
export class Chat extends EventHandler {
constructor() {
super();
this._url =
window.location.hostname === "localhost"
? "ws://localhost/chat.ws"
: "wss://" + window.location.hostname + "/chat.ws";
this._socket = null;
this._waitConnect = null;
this._promises = {};
}
connect() {
if (this._waitConnect) {
return this._waitConnect;
}
return new Promise((resolve) => {
this._waitConnect = resolve;
console.debug("Connecting..");
try {
this._socket = new WebSocket(this._url);
} catch (e) {
console.warn(e);
setTimeout(() => {
this.ensureConnection();
}, 1000);
}
this._socket.onconnect = () => {
this._connected();
this._waitSocket();
};
});
}
generateUniqueId() {
return "id-" + Math.random().toString(36).substr(2, 9);
}
call(method, ...args) {
return new Promise((resolve, reject) => {
try {
const command = { method, args, message_id: this.generateUniqueId() };
this._promises[command.message_id] = resolve;
this._socket.send(JSON.stringify(command));
} catch (e) {
reject(e);
}
});
}
_connected() {
this._socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.message_id && this._promises[message.message_id]) {
this._promises[message.message_id](message);
delete this._promises[message.message_id];
} else {
this.emit("message", message);
}
};
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit("close");
};
}
async privmsg(room, text) {
await rest.post("/api/privmsg", {
room,
text,
});
}
}
export class NotificationAudio {
constructor(timeout = 500) {
this.schedule = new Schedule(timeout);
}
sounds = {
message: "/audio/soundfx.d_beep3.mp3",
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
messageOtherChannel:
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
};
play(soundIndex = 0) {
this.schedule.delay(() => {
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
console.debug("Gave sound notification");
})
.catch((error) => {
console.error("Notification failed:", error);
});
});
}
}
export class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid;
}
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) {
if (this.is_pinging) return false;
this.is_pinging = true;
await this.rpc.ping(...args);
this.is_pinging = false;
}
async forcePing(...arg) {
await this.rpc.ping(...args);
}
starField = null
constructor() {
super();
this.ws = new Socket();
this.rpc = this.ws.client;
this.audio = new NotificationAudio(500);
this.is_pinging = false;
this.ping_interval = setInterval(() => {
this.ping("active");
}, 15000);
this.typeEventChannelUid = null;
this.typeListener = setInterval(() => {
if (this.typeEventChannelUid) {
this.rpc.set_typing(this.typeEventChannelUid);
this.typeEventChannelUid = null;
}
});
const me = this;
this.ws.addEventListener("connected", (data) => {
this.ping("online");
});
this.ws.addEventListener("reconnecting", (data) => {
this.starField?.showNotify("Connecting..","#CC0000")
})
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.ws.addEventListener("data", (data) => {
if(this._debug){
console.debug(data)
}
return result
}
get mentions() {
if(!this.text)
return []
let result = []
for(let part in this.text.split(/[,; ]/)){
if(part.startsWith("@")){
result.push(part)
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
}
}
return result
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? "days" : "day"} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
}
return "just now";
}
async benchMark(times = 100, message = "Benchmark Message") {
const promises = [];
const me = this;
for (let i = 0; i < times; i++) {
promises.push(
this.rpc.getChannels().then((channels) => {
channels.forEach((channel) => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}),
);
}
}
}
class Messages {
}
class Room {
name = null
messages = []
constructor(name){
this.name = name
}
setMessages(list){
}
}
class InlineAppElement extends HTMLElement {
constructor(){
this.
}
}
class Page {
elements = []
}
class App {
rooms = []
constructor() {
this.rooms.push(new Room("General"))
}
}
export const app = new App();
window.app = app;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
.back-form {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
fancy-button {
grid-column: 1 / 1;
grid-row: 1 / 1;
z-index: 1;
margin-left: 30px;
margin-top: 30px;
}
generic-form {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
}
+499 -111
View File
@@ -1,26 +1,53 @@
@import "shared.css";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
.gallery {
padding: 50px;
height: auto;
overflow: auto;
flex: 1;
}
.gallery.tile, .tile {
width: 100px;
height: 100px;
object-fit: cover;
margin: 20px 10px 20px 0;
border-radius: 5px;
}
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
background-color: #000000;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%;
height: 100%;
}
main {
min-width: 100%;
display: flex;
flex: 1;
overflow: hidden;
grid-area: chat-area;
}
header {
background-color: #0f0f0f;
grid-area: header;
padding: 10px 20px;
display: flex;
justify-content: space-between;
@@ -29,8 +56,9 @@ header {
header .logo {
color: #fff;
font-size: 1.5em;
font-weight: bold;
font-size: 1.2em;
color: #fff;
}
header nav a {
@@ -41,22 +69,294 @@ header nav a {
transition: color 0.3s;
}
.no-select {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
header nav a:hover {
color: #fff;
}
main {
display: flex;
a {
font-weight: bold;
color: #f05a28;
margin-bottom: 3px;
}
h1 {
font-size: 2em;
color: #f05a28;
}
h2 {
font-size: 1.4em;
color: #f05a28;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
user-select: none;
}
.chat-header h2 {
font-size: 1.2em;
color: #fff;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.message-list {
flex: 1;
height: 10px;
padding-bottom: 40px;
overflow-y: auto;
}
.chat-messages, .threads {
flex: 1;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 10px;
height: 10px;
}
.chat-messages {
display: flex;
flex-direction: column;
}
.container {
flex: 1;
padding: 10px;
ul {
list-style: none;
margin: 0;
padding: 0;
}
a {
font-size: 20px;
color: #f05a28;
}
}
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
.chat-messages .message, .threads .thread {
display: flex;
align-items: flex-start;
margin-bottom: 0;
padding: 5px;
border-radius: 8px;
}
.chat-messages .message .avatar, .threads .thread .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f05a28;
color: #fff;
font-size: 1em;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
}
.chat-messages .message .message-content, .threads .thread .message-content {
flex: 1;
}
.chat-messages .message .message-content .author, .threads .thread .message-content .author {
font-weight: bold;
color: #f05a28;
margin-bottom: 3px;
}
* {
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.highlight pre {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.chat-messages .message .message-content .text, .threads .thread .message-content .text {
margin-bottom: 5px;
color: #e6e6e6;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.message-content {
max-width: 100%;
}
.message-content {
img, video, iframe, div {
max-width: 90%;
border-radius: 20px;
}
}
.chat-messages .message .message-content .time, .threads .thread .message-content .time {
font-size: 0.8em;
color: #aaa;
}
.chat-input {
padding: 15px;
display: flex;
align-items: center;
}
input[type="text"], .chat-input textarea {
flex: 1;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
resize: none;
}
.chat-input upload-button {
color: white;
border: none;
padding: 10px 15px;
margin-left: 10px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s;
}
.chat-input button:hover {
background-color: #e04924;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
.message {
.text {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.avatar {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.author {
display: none;
}
&:not(:hover, :focus-within, :active) .time {
opacity: 0;
}
}
.message.switch-user {
.text img, iframe, video {
max-width: 90%;
border-radius: 20px;
}
.avatar {
user-select: none;
opacity: 1;
max-height: unset;
}
.author {
display: block;
}
}
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.time {
display: block;
opacity: 1;
}
}
/* The entire scrollbar */
::-webkit-scrollbar {
display: none;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 3px;
border: 1px solid #f1f1f1;
}
::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
.chat-messages, .threads {
scrollbar-width: none;
scrollbar-color: #888 #f1f1f1;
}
a {
text-decoration: none
}
.sidebar {
width: 250px;
background-color: #121212;
padding: 20px;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
overflow-y: auto;
border-right: 1px solid #333;
grid-area: sidebar;
}
.sidebar h2 {
@@ -84,116 +384,204 @@ main {
color: #fff;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background-color: #1a1a1a;
@keyframes glow {
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
}
.chat-header {
padding: 10px 20px;
background-color: #0f0f0f;
border-bottom: 1px solid #333;
.glow {
animation: glow 1s;
}
.chat-header h2 {
font-size: 1.2em;
color: #fff;
@media only screen and (max-width: 768px) {
header {
top: 0;
left: 0;
text-overflow: ellipsis;
width: 100%;
display: flex;
flex-direction: column;
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #1a1a1a;
}
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.chat-messages .message {
display: flex;
align-items: flex-start;
margin-bottom: 15px;
padding: 10px;
background: #222;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.chat-messages .message .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f05a28;
color: #fff;
font-size: 1em;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
}
.chat-messages .message .message-content {
flex: 1;
}
.chat-messages .message .message-content .author {
font-weight: bold;
color: #f05a28;
margin-bottom: 3px;
}
.chat-messages .message .message-content .text {
margin-bottom: 5px;
color: #e6e6e6;
}
.chat-messages .message .message-content .time {
font-size: 0.8em;
color: #aaa;
}
.chat-input {
padding: 15px;
background-color: #121212;
display: flex;
align-items: center;
border-top: 1px solid #333;
}
.chat-input textarea {
flex: 1;
background-color: #1a1a1a;
color: white;
border: none;
padding: 10px;
border-radius: 5px;
resize: none;
border-radius: 12px;
padding: 24px;
background-color: #000; /* Deep black */
color: #f1f1f1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
width: 90%;
max-width: 400px;
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
z-index: 1000;
}
.chat-input button {
/* Backdrop styling */
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Title and content */
dialog .dialog-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 16px;
color: #fff;
}
dialog .dialog-content {
font-size: 1rem;
color: #ccc;
margin-bottom: 20px;
}
/* Button layout */
dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
dialog .dialog-button {
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background 0.2s ease;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
background-color: #f05a28;
color: white;
border: none;
padding: 10px 15px;
margin-left: 10px;
border-radius: 5px;
cursor: pointer;
}
dialog .dialog-button.primary:hover {
background-color: #f05a28;
}
dialog .dialog-button.secondary {
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #f0b84c;
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
transition: background-color 0.3s;
}
.chat-input button:hover {
background-color: #e04924;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.chat-area {
flex: 1;
}
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
+373
View File
@@ -0,0 +1,373 @@
import { app } from "../app.js";
class ChatInputComponent extends HTMLElement {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
}
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
previousValue = ""
lastChange = null
changed = false
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
get value() {
return this._value;
}
set value(value) {
this._value = value || "";
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
}
resolveAutoComplete() {
let count = 0;
let value = null;
Object.keys(this.allAutoCompletions).forEach((key) => {
if (key.startsWith(this.value.split(" ")[0])) {
count++;
value = key;
}
});
if (count == 1) return value;
return null;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
getAuthors(){
let authors = []
for (let i = 0; i < this.users.length; i++) {
authors.push(this.users[i].username)
authors.push(this.users[i].nick)
}
return authors
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
}
return mentions;
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
distance += 10
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
async connectedCallback() {
this.user = null
app.rpc.getUser(null).then((user) => {
this.user=user
})
const me = this;
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
})
this.messageUid = null;
this.classList.add("chat-input");
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
this.dispatchEvent(new CustomEvent("upload", e));
});
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
});
this.appendChild(this.uploadButton);
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
this.value = "";
e.target.value = "";
return;
}
this.value = e.target.value;
this.changed = true;
this.update();
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const message = me.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
return;
}
this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = "";
this.previousValue = "";
this.messageUid = null;
}
});
this.changeInterval = setInterval(() => {
if (!this.liveType) {
return;
}
if (this.value !== this.previousValue) {
if (
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval
) {
this.value = "";
this.previousValue = "";
}
this.lastChange = new Date();
}
this.update();
}, 300);
this.addEventListener("upload", (e) => {
this.focus();
});
this.addEventListener("uploaded", function (e) {
let message = "";
e.detail.files.forEach((file) => {
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
});
app.rpc.sendMessage(this.channelUid, message,true);
});
setTimeout(()=>{
this.focus();
},1000)
}
trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
}
return i === s.length;
}
newMessage() {
if (!this.messageUid) {
this.messageUid = "?";
}
this.value = this.replaceMentionsWithAuthors(this.value);
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
if (this.liveType) {
this.messageUid = uid;
}
});
}
updateMessage() {
if (this.value[0] == "/") {
return false;
}
if (!this.messageUid) {
this.newMessage();
return false;
}
if (this.messageUid === "?") {
return false;
}
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
}
}
updateStatus() {
if (this.liveType) {
return;
}
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
this.lastUpdateEvent = new Date();
if (
typeof app !== "undefined" &&
app.rpc &&
typeof app.rpc.set_typing === "function"
) {
app.rpc.set_typing(this.channelUid, this.user.color);
}
}
}
update() {
const expired =
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
this.liveTypeInterval;
const changed = this.value !== this.previousValue;
if (changed || expired) {
this.lastChange = new Date();
this.updateStatus();
}
this.previousValue = this.value;
if (this.liveType && expired) {
this.value = "";
this.previousValue = "";
this.messageUid = null;
return;
}
if (changed) {
if (this.liveType) {
this.updateMessage();
}
}
}
async sendMessage(channelUid, value,is_final) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value,is_final);
}
}
customElements.define("chat-input", ChatInputComponent);
+82
View File
@@ -0,0 +1,82 @@
// Written by retoor@molodetz.nl
// This code defines a custom HTML element called ChatWindowElement that provides a chat interface within a shadow DOM, handling connection callbacks, displaying messages, and user interactions.
// No external imports or includes other than standard DOM and HTML elements.
// The MIT License (MIT)
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class ChatWindowElement extends HTMLElement {
receivedHistory = false;
channel = null;
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("section");
this.app = app;
this.shadowRoot.appendChild(this.component);
}
get user() {
return this.app.user;
}
async connectedCallback() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/base.css";
this.component.appendChild(link);
this.component.classList.add("chat-area");
this.container = document.createElement("section");
this.container.classList.add("chat-area", "chat-window");
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
const chatTitle = document.createElement("h2");
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
chatHeader.appendChild(chatTitle);
this.container.appendChild(chatHeader);
const channels = await app.rpc.getChannels();
const channel = channels[0];
this.channel = channel;
chatTitle.innerText = channel.name;
const channelElement = document.createElement("message-list");
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
const chatInput = document.createElement("chat-input");
chatInput.chatWindow = this;
chatInput.addEventListener("submit", (e) => {
app.rpc.sendMessage(channel.uid, e.detail);
});
this.container.appendChild(chatInput);
this.component.appendChild(this.container);
const messages = await app.rpc.getMessages(channel.uid);
messages.forEach((message) => {
if (!message["user_nick"]) return;
channelElement.addMessage(message);
});
const me = this;
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({ block: "end" });
});
}
}
customElements.define("chat-window", ChatWindowElement);
+150
View File
@@ -0,0 +1,150 @@
class DumbTerminal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
--terminal-bg: #111;
--terminal-fg: #0f0;
--terminal-accent: #0ff;
--terminal-font: monospace;
display: block;
background: var(--terminal-bg);
color: var(--terminal-fg);
font-family: var(--terminal-font);
padding: 1rem;
border-radius: 8px;
overflow-y: auto;
max-height: 500px;
}
.output {
white-space: pre-wrap;
margin-bottom: 1em;
}
.input-line {
display: flex;
}
.prompt {
color: var(--terminal-accent);
margin-right: 0.5em;
}
input {
background: transparent;
border: none;
color: var(--terminal-fg);
outline: none;
width: 100%;
font-family: inherit;
font-size: inherit;
}
dialog {
border: none;
background: transparent;
}
.dialog-backdrop {
background: rgba(0, 0, 0, 0.8);
padding: 2rem;
}
</style>
<div class="output" id="output"></div>
<div class="input-line">
<span class="prompt">&gt;</span>
<input type="text" id="input" autocomplete="off" autofocus />
</div>
`;
this.outputEl = this.shadowRoot.getElementById("output");
this.inputEl = this.shadowRoot.getElementById("input");
this.history = [];
this.historyIndex = -1;
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
}
onKeyDown(event) {
const value = this.inputEl.value;
switch (event.key) {
case "Enter":
this.executeCommand(value);
this.history.push(value);
this.historyIndex = this.history.length;
this.inputEl.value = "";
break;
case "ArrowUp":
if (this.historyIndex > 0) {
this.historyIndex--;
this.inputEl.value = this.history[this.historyIndex];
}
break;
case "ArrowDown":
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.inputEl.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.inputEl.value = "";
}
break;
}
}
executeCommand(command) {
const outputLine = document.createElement("div");
outputLine.textContent = `> ${command}`;
this.outputEl.appendChild(outputLine);
const resultLine = document.createElement("div");
resultLine.textContent = this.mockExecute(command);
this.outputEl.appendChild(resultLine);
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
mockExecute(command) {
switch (command.trim()) {
case "help":
return "Available commands: help, clear, date";
case "date":
return new Date().toString();
case "clear":
this.outputEl.innerHTML = "";
return "";
default:
return `Unknown command: ${command}`;
}
}
/**
* Static method to create a modal dialog with the terminal
* @returns {HTMLDialogElement}
*/
static createModal() {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<div class="dialog-backdrop">
<web-terminal></web-terminal>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
return dialog;
}
}
customElements.define("web-terminal", WebTerminal);
Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

+15
View File
@@ -0,0 +1,15 @@
export class EventHandler {
constructor() {
this.subscribers = {};
}
addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = [];
this.subscribers[type].push(handler);
}
emit(type, ...data) {
if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data));
}
}
+52 -45
View File
@@ -1,30 +1,30 @@
// Written by retoor@molodetz.nl
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
// MIT License
class FancyButton extends HTMLElement {
url = null
type="button"
value = null
constructor(){
super()
this.attachShadow({mode:'open'})
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.url = null;
this.type = "button";
this.value = null;
}
connectedCallback() {
connectedCallback() {
this.container = document.createElement("span");
let size = this.getAttribute("size");
console.info({ GG: size });
size = size === "auto" ? "1%" : "33%";
this.container = document.createElement('span')
let size = this.getAttribute('size')
console.info({GG:size})
if(size == 'auto'){
size = '1%'
}else{
size = '33%'
}
this.styleElement = document.createElement("style")
this.styleElement.innerHTML = `
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
:root {
width:100%;
width: 100%;
--width: 100%;
}
}
button {
width: var(--width);
min-width: ${size};
@@ -37,32 +37,39 @@ class FancyButton extends HTMLElement {
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
border: 1px solid #f05a28;
}
border: 1px solid #f05a28;
}
button:hover {
color: #EFEFEF;
background-color: #e04924;
border: 1px solid #efefef;
color: #EFEFEF;
background-color: #e04924;
border: 1px solid #efefef;
}
`;
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement("button");
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.url = this.getAttribute("url");
this.value = this.getAttribute("value");
this.buttonElement.appendChild(
document.createTextNode(this.getAttribute("text")),
);
this.buttonElement.addEventListener("click", () => {
if (this.url == "submit") {
this.closest("form").submit();
return;
}
if (this.url === "/back" || this.url === "/back/") {
window.history.back();
} else if (this.url) {
window.location = this.url;
}
});
}
`
this.container.appendChild(this.styleElement)
this.buttonElement = document.createElement('button')
this.container.appendChild(this.buttonElement)
this.shadowRoot.appendChild(this.container)
this.url = this.getAttribute('url');
this.value = this.getAttribute('value')
const me = this
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")))
this.buttonElement.addEventListener("click",()=>{
if(me.url == "/back" || me.url == "/back/"){
window.history.back()
}else if(me.url){
window.location = me.url
}
})
}
}
customElements.define("fancy-button",FancyButton)
customElements.define("fancy-button", FancyButton);
+41
View File
@@ -0,0 +1,41 @@
.file-manager {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
padding: 20px;
background: #111;
color: #ddd;
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
border-radius: 8px;
}
.file-tile {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
text-align: center;
padding: 10px;
transition: transform 0.2s;
}
.file-tile:hover {
transform: translateY(-5px);
}
.file-icon {
font-size: 40px;
margin-bottom: 10px;
color: #888;
}
.file-name {
font-size: 14px;
overflow-wrap: break-word;
}
.file-tile img {
max-width: 80%;
height: auto;
margin-bottom: 10px;
border-radius: 4px;
}
+116
View File
@@ -0,0 +1,116 @@
/* A <file-browser> custom element that talks to /api/files */
class FileBrowser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.path = ""; // current virtual path ("" = ROOT)
this.offset = 0; // pagination offset
this.limit = 40; // items per request
}
connectedCallback() {
this.path = this.getAttribute("path") || "";
this.renderShell();
this.load();
}
// ---------- UI scaffolding -------------------------------------------
renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }
nav { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }
button { padding:.35rem .65rem; border:none; border-radius:4px; background:#f05a28; color:#fff; cursor:pointer; font:inherit; }
button:disabled { background:#999; cursor:not-allowed; }
.crumb { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }
.tile { border:1px solid #f05a28; border-radius:8px; padding:.5rem; background:#000000; text-align:center; cursor:pointer; transition:box-shadow .2s ease; }
.tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }
img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }
.icon { font-size:48px; line-height:90px; }
</style>
<nav>
<button id="up">⬅️ Up</button>
<span class="crumb" id="crumb"></span>
</nav>
<div class="grid" id="grid"></div>
<nav>
<button id="prev">Prev</button>
<button id="next">Next</button>
</nav>
`;
this.shadowRoot
.getElementById("up")
.addEventListener("click", () => this.goUp());
this.shadowRoot.getElementById("prev").addEventListener("click", () => {
if (this.offset > 0) {
this.offset -= this.limit;
this.load();
}
});
this.shadowRoot.getElementById("next").addEventListener("click", () => {
this.offset += this.limit;
this.load();
});
}
// ---------- Networking ----------------------------------------------
async load() {
const r = await fetch(
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
);
if (!r.ok) {
console.error(await r.text());
return;
}
const data = await r.json();
this.renderTiles(data.items);
this.updateNav(data.pagination);
}
// ---------- Rendering -------------------------------------------------
renderTiles(items) {
const grid = this.shadowRoot.getElementById("grid");
grid.innerHTML = "";
items.forEach((item) => {
const tile = document.createElement("div");
tile.className = "tile";
if (item.type === "directory") {
tile.innerHTML = `<div class="icon">đź“‚</div><div>${item.name}</div>`;
tile.addEventListener("click", () => {
this.path = item.path;
this.offset = 0;
this.load();
});
} else {
if (item.mimetype?.startsWith("image/")) {
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
} else {
tile.innerHTML = `<div class="icon">đź“„</div><div>${item.name}</div>`;
}
tile.addEventListener("click", () => window.open(item.url, "_blank"));
}
grid.appendChild(tile);
});
}
// ---------- Navigation + pagination ----------------------------------
updateNav({ offset, limit, total }) {
this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
this.shadowRoot.getElementById("prev").disabled = offset === 0;
this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
this.shadowRoot.getElementById("up").disabled = this.path === "";
}
goUp() {
if (!this.path) return;
this.path = this.path.split("/").slice(0, -1).join("/");
this.offset = 0;
this.load();
}
}
customElements.define("file-manager", FileBrowser);
+4 -5
View File
@@ -14,6 +14,7 @@
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
}
generic-form {
@@ -29,16 +30,15 @@ generic-form {
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
width: 400px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
.generic-form-container h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
}
input {
@@ -93,8 +93,7 @@ input {
}
@media (max-width: 500px) {
@media (max-width: 600px) {
.generic-form-container {
width: 90%;
}
}
}
+411 -310
View File
@@ -1,347 +1,448 @@
// Written by retoor@molodetz.nl
// This code defines two custom HTML elements, `GenericField` and `GenericForm`. The `GenericField` element represents a form field with validation and styling functionalities, and the `GenericForm` fetches and manages form data, handling field validation and submission.
// No external imports are present; all utilized functionality is native to JavaScript and web APIs.
// MIT License
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
class GenericField extends HTMLElement {
form = null
field = null
inputElement = null
footerElement = null
action = null
container = null
styleElement = null
name = null
get value() {
return this.inputElement.value
}
get type() {
form = null;
field = null;
inputElement = null;
footerElement = null;
action = null;
container = null;
styleElement = null;
name = null;
return this.field.tag
}
set value(val) {
val = val == null ? '' : val
this.inputElement.value = val
this.inputElement.setAttribute("value", val)
}
setInvalid(){
this.inputElement.classList.add("error")
this.inputElement.classList.remove("valid")
}
setErrors(errors){
if(errors.length)
this.inputElement.setAttribute("title", errors[0])
else
this.inputElement.setAttribute("title","")
}
setValid(){
this.inputElement.classList.remove("error")
this.inputElement.classList.add("valid")
}
constructor() {
super()
this.attachShadow({mode:'open'})
this.container = document.createElement('div')
this.styleElement = document.createElement('style')
this.styleElement.innerHTML = `
get value() {
return this.inputElement.value;
}
h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
margin-top: 0px;
}
get type() {
return this.field.tag;
}
input {
width: 90%;
padding: 10px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 5px;
background-color: #1a1a1a;
color: #e6e6e6;
font-size: 1em;
}
set value(val) {
val = val ?? "";
this.inputElement.value = val;
this.inputElement.setAttribute("value", val);
}
button {
width: 50%;
padding: 10px;
background-color: #f05a28;
border: none;
float: right;
margin-top: 10px;
margin-left: 10px;
margin-right: 10px;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
clear: both;
}
setInvalid() {
this.inputElement.classList.add("error");
this.inputElement.classList.remove("valid");
}
button:hover {
background-color: #e04924;
}
setErrors(errors) {
const errorText = errors.length ? errors[0] : "";
this.inputElement.setAttribute("title", errorText);
}
a {
color: #f05a28;
text-decoration: none;
display: block;
margin-top: 15px;
font-size: 0.9em;
transition: color 0.3s;
}
setValid() {
this.inputElement.classList.remove("error");
this.inputElement.classList.add("valid");
}
a:hover {
color: #e04924;
}
.valid {
border: 1px solid green;
color:green;
font-size: 0.9em;
margin-top: 5px;
}
.error {
border: 3px solid red;
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
}
@media (max-width: 500px) {
input {
width: 90%;
}
}
`
this.container.appendChild(this.styleElement)
this.shadowRoot.appendChild(this.container)
}
connectedCallback(){
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
this.updateAttributes()
}
setAttribute(name,value){
this[name] = value
}
updateAttributes(){
if(this.inputElement == null && this.field){
this.inputElement = document.createElement(this.field.tag)
if(this.field.tag == 'button'){
if(this.field.value == "submit"){
}
this.action = this.field.value
}
this.inputElement.name = this.field.name
this.name = this.inputElement.name
const me = this
this.inputElement.addEventListener("keyup",(e)=>{
if(e.key == 'Enter'){
me.dispatchEvent(new Event("submit"))
}else if(me.field.value != e.target.value)
{
const event = new CustomEvent("change", {detail:me,bubbles:true})
me.dispatchEvent(event)
}
})
this.inputElement.addEventListener("click",(e)=>{
const event = new CustomEvent("click",{detail:me,bubbles:true})
me.dispatchEvent(event)
})
this.container.appendChild(this.inputElement)
}
if(!this.field){
return
h1 {
font-size: 2em;
color: #f05a28;
margin-bottom: 20px;
margin-top: 0px;
}
this.inputElement.setAttribute("type",this.field.type == null ? 'input' : this.field.type)
this.inputElement.setAttribute("name",this.field.name == null ? '' : this.field.name)
input {
width: 90%;
padding: 10px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 5px;
background-color: #1a1a1a;
color: #e6e6e6;
font-size: 1em;
if(this.field.text != null){
this.inputElement.innerText = this.field.text
&:focus {
outline: 2px solid #f05a28 !important;
}
if(this.field.html != null){
this.inputElement.innerHTML = this.field.html
&::placeholder {
transition: opacity 0.3s;
}
if(this.field.class_name){
this.inputElement.classList.add(this.field.class_name)
&:focus::placeholder {
opacity: 0.4;
}
this.inputElement.setAttribute("tabindex", this.field.index)
this.inputElement.classList.add(this.field.name)
this.value = this.field.value
let place_holder = null
if(this.field.place_holder)
place_holder = this.field.place_holder
if(this.field.required && place_holder){
place_holder = place_holder
}
button {
width: 50%;
padding: 10px;
background-color: #f05a28;
border: none;
float: right;
margin-top: 10px;
margin-left: 10px;
margin-right: 10px;
border-radius: 5px;
color: white;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
clear: both;
}
button:hover {
background-color: #e04924;
}
a {
color: #f05a28;
text-decoration: none;
display: block;
margin-top: 15px;
font-size: 0.9em;
transition: color 0.3s;
}
a:hover {
color: #e04924;
}
.valid {
border: 1px solid green;
color: green;
font-size: 0.9em;
margin-top: 5px;
}
.error {
border: 3px solid red;
color: #d8000c;
font-size: 0.9em;
margin-top: 5px;
}
@media (max-width: 500px) {
input {
width: 90%;
}
if(place_holder)
this.field.place_holder = "* " + place_holder
this.inputElement.setAttribute("placeholder",place_holder)
if(this.field.required)
this.inputElement.setAttribute("required","required")
else
this.inputElement.removeAttribute("required")
if(!this.footerElement){
this.footerElement = document.createElement('div')
this.footerElement.style.clear = 'both'
this.container.appendChild(this.footerElement)
}
`;
this.container.appendChild(this.styleElement);
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.updateAttributes();
}
setAttribute(name, value) {
this[name] = value;
}
focus(options) {
this.inputElement?.focus(options);
}
updateAttributes() {
const inputUpdate = this.inputElement != null;
if (this.inputElement == null && this.field) {
this.inputElement = document.createElement(this.field.tag);
if (this.field.tag === "button" && this.field.value === "submit") {
this.action = this.field.value;
}
this.inputElement.name = this.field.name;
this.name = this.inputElement.name;
const me = this;
this.inputElement.addEventListener("keyup", (e) => {
if (e.key === "Enter") {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
me.dispatchEvent(new Event("submit"));
} else if (me.field.value !== e.target.value) {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
}
});
this.inputElement.addEventListener("click", (e) => {
const event = new CustomEvent("click", { detail: me, bubbles: true });
me.dispatchEvent(event);
});
this.inputElement.addEventListener(
"blur",
(e) => {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
},
true,
);
this.container.appendChild(this.inputElement);
}
if (!this.field) {
return;
}
this.inputElement.setAttribute("type", this.field.type ?? "input");
this.inputElement.setAttribute("name", this.field.name ?? "");
if (this.field.text != null) {
this.inputElement.innerText = this.field.text;
}
if (this.field.html != null) {
this.inputElement.innerHTML = this.field.html;
}
if (this.field.class_name) {
this.inputElement.classList.add(this.field.class_name);
}
this.inputElement.setAttribute("tabindex", this.field.index);
this.inputElement.classList.add(this.field.name);
if (this.field.value != null || !inputUpdate) {
this.value = this.field.value;
}
let place_holder = this.field.place_holder ?? null;
if (this.field.required && place_holder) {
place_holder = "* " + place_holder;
}
this.inputElement.setAttribute("placeholder", place_holder);
if (this.field.required) {
this.inputElement.setAttribute("required", "required");
} else {
this.inputElement.removeAttribute("required");
}
if (!this.footerElement) {
this.footerElement = document.createElement("div");
this.footerElement.style.clear = "both";
this.container.appendChild(this.footerElement);
}
}
}
customElements.define('generic-field', GenericField);
customElements.define("generic-field", GenericField);
class GenericForm extends HTMLElement {
fields = {}
form = {}
fields = {};
form = {};
constructor() {
super();
this.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
super();
this.attachShadow({ mode: 'open' });
this.styleElement = document.createElement("style")
this.styleElement.innerHTML = `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
width:90%
}
div {
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
width: 400px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
@media (max-width: 500px) {
width:100%;
height:100%;
form {
height:100%;
width: 100%;
width: 80%;
}
}`
this.container = document.createElement('div');
this.container.appendChild(this.styleElement)
this.container.classList.add("generic-form-container")
this.shadowRoot.appendChild(this.container);
* {
margin: 0;
padding: 0;
box-sizing: border-box;
width: 90%;
}
connectedCallback() {
const url = this.getAttribute('url');
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
if(!url.startsWith("/"))
fullUrl.searchParams.set('url', url)
this.loadForm(fullUrl.toString());
} else {
this.container.textContent = "No URL provided!";
}
div {
min-width:350px;
border-radius: 10px;
padding: 30px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
@media (max-width: 500px) {
width: 100%;
height: 100%;
form {
height: 100%;
width: 80%;
}
}`;
async loadForm(url) {
const me = this
this.container = document.createElement("div");
this.container.appendChild(this.styleElement);
this.container.classList.add("generic-form-container");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
const preloadedForm = this.getAttribute("preloaded-structure");
if (preloadedForm) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
me.form = await response.json();
let fields = Object.values(me.form.fields)
fields = fields.sort((a,b)=>{
console.info(a.index,b.index)
return a.index - b.index
})
fields.forEach(field=>{
const fieldElement = document.createElement('generic-field')
me.fields[field.name] = fieldElement
fieldElement.setAttribute("form", me)
fieldElement.setAttribute("field", field)
me.container.appendChild(fieldElement)
fieldElement.updateAttributes()
fieldElement.addEventListener("change",(e)=>{
me.form.fields[e.detail.name].value = e.detail.value
})
fieldElement.addEventListener("click",async (e)=>{
if(e.detail.type == "button"){
if(e.detail.value == "submit")
{
const isValid = await me.validate()
if(isValid){
const isProcessed = await me.submit()
console.info({processed:isProcessed})
const form = JSON.parse(preloadedForm);
this.constructForm(form);
} catch (error) {
console.error(error, preloadedForm);
}
}
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set("url", url);
}
this.loadForm(fullUrl.toString());
} else {
this.container.textContent = "No URL provided!";
}
}
async constructForm(formPayload) {
try {
this.form = formPayload;
let fields = Object.values(this.form.fields);
let hasAutoFocus = Object.keys(this.fields).length !== 0;
fields.sort((a, b) => a.index - b.index);
fields.forEach((field) => {
const updatingField = field.name in this.fields;
this.fields[field.name] ??= document.createElement("generic-field");
const fieldElement = this.fields[field.name];
fieldElement.setAttribute("form", this);
fieldElement.setAttribute("field", field);
fieldElement.updateAttributes();
if (!updatingField) {
this.container.appendChild(fieldElement);
if (!hasAutoFocus && field.tag === "input") {
fieldElement.focus();
hasAutoFocus = true;
}
fieldElement.addEventListener("change", (e) => {
this.form.fields[e.detail.name].value = e.detail.value;
});
fieldElement.addEventListener("click", async (e) => {
if (e.detail.type === "button" && e.detail.value === "submit") {
const isValid = await this.validate();
if (isValid) {
const saveResult = await this.submit();
if (saveResult.redirect_url) {
window.location.pathname = saveResult.redirect_url;
}
}
}
})
})
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
async validate(){
const url = this.getAttribute("url")
const me = this
let response = await fetch(url,{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({"action":"validate", "form":me.form})
});
});
const form = await response.json()
Object.values(form.fields).forEach(field=>{
if(!me.form.fields[field.name])
return
me.form.fields[field.name].is_valid = field.is_valid
if(!field.is_valid){
me.fields[field.name].setInvalid()
me.fields[field.name].setErrors(field.errors)
console.info(field.name,"is invalid")
}else{
me.fields[field.name].setValid()
fieldElement.addEventListener("submit", async (e) => {
const isValid = await this.validate();
if (isValid) {
const saveResult = await this.submit();
if (saveResult.redirect_url) {
window.location.pathname = saveResult.redirect_url;
}
}
});
}
me.fields[field.name].setAttribute("field",field)
me.fields[field.name].updateAttributes()
})
Object.values(form.fields).forEach(field=>{
console.info(field.errors)
me.fields[field.name].setErrors(field.errors)
})
console.info({XX:form})
return form['is_valid']
}
async submit(){
const me = this
const url = me.getAttribute("url")
const response = await fetch(url,{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({"action":"submit", "form":me.form})
});
return await response.json()
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
customElements.define('generic-form', GenericForm);
async loadForm(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
await this.constructForm(await response.json());
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
async validate() {
const url = this.getAttribute("url");
let response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: "validate", form: this.form }),
});
const form = await response.json();
Object.values(form.fields).forEach((field) => {
if (!this.form.fields[field.name]) {
return;
}
this.form.fields[field.name].is_valid = field.is_valid;
if (!field.is_valid) {
this.fields[field.name].setInvalid();
this.fields[field.name].setErrors(field.errors);
} else {
this.fields[field.name].setValid();
}
this.fields[field.name].setAttribute("field", field);
this.fields[field.name].updateAttributes();
});
Object.values(form.fields).forEach((field) => {
this.fields[field.name].setErrors(field.errors);
});
return form["is_valid"];
}
async submit() {
const url = this.getAttribute("url");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: "submit", form: this.form }),
});
return await response.json();
}
}
customElements.define("generic-form", GenericForm);
+76
View File
@@ -0,0 +1,76 @@
pre { line-height: 125%; white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.hll { background-color: #ffffcc }
.c { color: #3D7B7B; font-style: italic } /* Comment */
.err { border: 1px solid #F00 } /* Error */
.k { color: #008000; font-weight: bold } /* Keyword */
.o { color: #666 } /* Operator */
.ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
.cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
.cp { color: #9C6500 } /* Comment.Preproc */
.cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
.c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
.cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
.gd { color: #A00000 } /* Generic.Deleted */
.ge { font-style: italic } /* Generic.Emph */
.ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */
.gr { color: #E40000 } /* Generic.Error */
.gh { color: #000080; font-weight: bold } /* Generic.Heading */
.gi { color: #008400 } /* Generic.Inserted */
.go { color: #717171 } /* Generic.Output */
.gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.gs { font-weight: bold } /* Generic.Strong */
.gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.gt { color: #04D } /* Generic.Traceback */
.kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
.kp { color: #008000 } /* Keyword.Pseudo */
.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.kt { color: #B00040 } /* Keyword.Type */
.m { color: #666 } /* Literal.Number */
.s { color: #BA2121 } /* Literal.String */
.na { color: #687822 } /* Name.Attribute */
.nb { color: #008000 } /* Name.Builtin */
.nc { color: #00F; font-weight: bold } /* Name.Class */
.no { color: #800 } /* Name.Constant */
.nd { color: #A2F } /* Name.Decorator */
.ni { color: #717171; font-weight: bold } /* Name.Entity */
.ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
.nf { color: #00F } /* Name.Function */
.nl { color: #767600 } /* Name.Label */
.nn { color: #00F; font-weight: bold } /* Name.Namespace */
.nt { color: #008000; font-weight: bold } /* Name.Tag */
.nv { color: #19177C } /* Name.Variable */
.ow { color: #A2F; font-weight: bold } /* Operator.Word */
.w { color: #BBB } /* Text.Whitespace */
.mb { color: #666 } /* Literal.Number.Bin */
.mf { color: #666 } /* Literal.Number.Float */
.mh { color: #666 } /* Literal.Number.Hex */
.mi { color: #666 } /* Literal.Number.Integer */
.mo { color: #666 } /* Literal.Number.Oct */
.sa { color: #BA2121 } /* Literal.String.Affix */
.sb { color: #BA2121 } /* Literal.String.Backtick */
.sc { color: #BA2121 } /* Literal.String.Char */
.dl { color: #BA2121 } /* Literal.String.Delimiter */
.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.s2 { color: #BA2121 } /* Literal.String.Double */
.se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
.sh { color: #BA2121 } /* Literal.String.Heredoc */
.si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
.sx { color: #008000 } /* Literal.String.Other */
.sr { color: #A45A77 } /* Literal.String.Regex */
.s1 { color: #BA2121 } /* Literal.String.Single */
.ss { color: #19177C } /* Literal.String.Symbol */
.bp { color: #008000 } /* Name.Builtin.Pseudo */
.fm { color: #00F } /* Name.Function.Magic */
.vc { color: #19177C } /* Name.Variable.Class */
.vg { color: #19177C } /* Name.Variable.Global */
.vi { color: #19177C } /* Name.Variable.Instance */
.vm { color: #19177C } /* Name.Variable.Magic */
.il { color: #666 } /* Literal.Number.Integer.Long */
+50 -37
View File
@@ -1,43 +1,56 @@
// Written by retoor@molodetz.nl
// The following JavaScript code defines a custom HTML element `<html-frame>` that loads and displays HTML content from a specified URL. If the URL is provided as a markdown file, it attempts to render it as HTML.
// Uses the `HTMLElement` class and the methods `attachShadow`, `createElement`, `fetch`, and `define` to extend and manipulate HTML elements.
// MIT License
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add("html_frame")
const url = this.getAttribute('url');
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
if(!url.startsWith("/"))
fullUrl.searchParams.set('url', url)
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute("url");
if (!url.startsWith("https")) {
url = "https://" + url;
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if(url.endsWith(".md")){
const parent = this
const markdownElement = document.createElement('div')
markdownElement.innerHTML = html
this.outerHTML = html
}else{
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
if (url) {
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set("url", url);
}
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
}
customElements.define('html-frame', HTMLFrame);
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement("div");
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
this.container.innerHTML = html;
}
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
}
customElements.define("html-frame", HTMLFrame);
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+30
View File
@@ -0,0 +1,30 @@
{
"id": "snek",
"name": "Snek",
"description": "Danger noodle",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"theme_color": "#000000",
"background_color": "#000000",
"related_applications": [],
"prefer_related_applications": false,
"screenshots": [],
"dir": "ltr",
"lang": "en-US",
"launch_path": "/web.html",
"short_name": "Snek",
"start_url": "/web.html",
"icons": [
{
"src": "/image/snek192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+41 -32
View File
@@ -1,39 +1,48 @@
// Written by retoor@molodetz.nl
// This JavaScript class defines a custom HTML element <markdown-frame> that fetches and loads content from a specified URL into a shadow DOM.
// Utilizes built-in JavaScript functionalities and fetching APIs. No external libraries or imports are used.
// The MIT License (MIT)
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.shadowRoot.appendChild(this.container);
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add("html_frame")
const url = this.getAttribute('url');
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
if(!url.startsWith("/"))
fullUrl.searchParams.set('url', url)
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
}
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
this.container.innerHTML = html;
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
connectedCallback() {
this.container.classList.add("html_frame");
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = "No source URL!";
}
}
customElements.define('markdown-frame', HTMLFrame);
async loadAndRender(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const html = await response.text();
this.container.innerHTML = html;
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
}
customElements.define("markdown-frame", HTMLFrame);
+173
View File
@@ -0,0 +1,173 @@
// Written by retoor@molodetz.nl
// This code defines custom web components to create and interact with a tile grid system for displaying images, along with an upload button to facilitate image additions.
// No external libraries or dependencies are used other than standard web components.
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class TileGridElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.gridId = this.getAttribute("grid");
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.grid {
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.grid .tile {
margin: 10px;
width: 100px;
height: 100px;
background-color: #ccc;
object-fit: cover;
border-radius: 10px;
transition: transform 0.3s ease-in-out;
}
.grid .tile:hover {
transform: scale(1.1);
}
`;
this.component.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("gallery");
this.component.appendChild(this.container);
}
addImage(src) {
const item = document.createElement("img");
item.src = src;
item.classList.add("tile");
item.style.width = "100px";
item.style.height = "100px";
this.container.appendChild(item);
}
addImages(srcs) {
srcs.forEach((src) => this.addImage(src));
}
addElement(element) {
element.classList.add("tile");
this.container.appendChild(element);
}
}
class UploadButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
window.u = this;
}
get gridSelector() {
return this.getAttribute("grid");
}
grid = null;
addImages(urls) {
this.grid.addImages(urls);
}
connectedCallback() {
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.upload-button input[type="file"] {
display: none;
}
.upload-button label {
display: block;
padding: 10px 20px;
background-color: #ccc;
border-radius: 5px;
cursor: pointer;
}
.upload-button label:hover {
background-color: #999;
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement("div");
this.container.classList.add("upload-button");
this.shadowRoot.appendChild(this.container);
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.addEventListener("change", (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (e) => {
urls.push(e.target.result);
if (urls.length === files.length) {
this.addImages(urls);
}
};
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement("label");
label.textContent = "Upload Images";
label.appendChild(input);
this.container.appendChild(label);
}
}
customElements.define("upload-button", UploadButton);
customElements.define("tile-grid", TileGridElement);
class MeniaUploadElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
alert("aaaa");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = "100%";
this.component.style.backgroundColor = "blue";
this.shadowRoot.appendChild(this.container);
this.tileElement = document.createElement("tile-grid");
this.tileElement.style.backgroundColor = "red";
this.tileElement.style.height = "100%";
this.component.appendChild(this.tileElement);
this.uploadButton = document.createElement("upload-button");
this.component.appendChild(this.uploadButton);
}
}
customElements.define("menia-upload", MeniaUploadElement);

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