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
170 changed files with 11213 additions and 1891 deletions
+1
View File
@@ -1,3 +1,4 @@
.r_history
.vscode
.history
.resources
+4 -34
View File
@@ -1,40 +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
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"]
+1 -1
View File
@@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh
COPY pyproject.toml pyproject.toml
COPY src src
COpy ssh_host_key ssh_host_key
COPY ssh_host_key ssh_host_key
RUN pip install --upgrade pip
RUN pip install -e .
EXPOSE 2225
+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 .
+4 -14
View File
@@ -2,27 +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"]
#entrypoint: ["python","-m","snek.app"]
snecssh:
build:
context: .
dockerfile: DockerfileDrive
restart: always
ports:
- "2225:2225"
volumes:
- ./:/code
environment:
- PYTHONDONTWRITEBYTECODE="1"
- PYTHONUNBUFFERED="1"
entrypoint: ["python","-m","snekssh.app2"]
#["python","-m","snek.app"]
+19 -2
View File
@@ -15,8 +15,11 @@ 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",
@@ -28,6 +31,20 @@ dependencies = [
"asyncssh",
"emoji",
"aiofiles",
"PyJWT"
"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"
+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()
+263 -26
View File
@@ -1,10 +1,16 @@
import pathlib
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,
@@ -13,28 +19,47 @@ from aiohttp_session import (
)
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from app.app import Application as BaseApplication
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.cache import Cache
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import cors_middleware
from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension
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.logout import LogoutView
from snek.view.register import RegisterView
from snek.view.rpc import RPCView
from snek.view.status import StatusView
from snek.view.web import WebView
from snek.view.upload import UploadView
from snek.view.repository import RepositoryView
from snek.view.search_user import SearchUserView
from snek.system.profiler import profiler_handler
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 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"
@@ -44,6 +69,15 @@ async def session_middleware(request, handler):
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):
def __init__(self, *args, **kwargs):
@@ -52,29 +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.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off")
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"])
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()
@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)
@@ -92,24 +205,51 @@ class Application(BaseApplication):
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", LoginView)
self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView)
self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/drive.bin", UploadView)
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView)
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.add_subapp(
"/docs",
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
)
#self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
@@ -131,14 +271,111 @@ class Application(BaseApplication):
# @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
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__":
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...")
+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())
-1
View File
@@ -16,4 +16,3 @@ class SearchUserForm(Form):
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"
)
+11 -2
View File
@@ -3,14 +3,19 @@ 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.notification import NotificationMapper
from snek.mapper.user import UserMapper
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
def get_mappers(app=None):
return Object(
**{
@@ -21,6 +26,10 @@ def get_mappers(app=None):
"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),
}
)
+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
+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"
+1 -1
View File
@@ -3,5 +3,5 @@ from snek.system.mapper import BaseMapper
class DriveMapper(BaseMapper):
table_name = 'drive'
table_name = "drive"
model_class = DriveModel
+3 -2
View File
@@ -1,7 +1,8 @@
from snek.system.mapper import BaseMapper
from snek.model.drive_item import DriveItemModel
from snek.system.mapper import BaseMapper
class DriveItemMapper(BaseMapper):
model_class = DriveItemModel
table_name = 'drive_item'
table_name = "drive_item"
+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"
+13
View File
@@ -5,3 +5,16 @@ from snek.system.mapper import BaseMapper
class UserMapper(BaseMapper):
table_name = "user"
model_class = UserModel
def get_admin_uids(self):
try:
return [
user["uid"]
for user in self.db.query(
"SELECT uid FROM user WHERE is_admin = :is_admin",
{"is_admin": True},
)
]
except Exception as ex:
print(ex)
return []
+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
+14
View File
@@ -5,7 +5,14 @@ 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
@@ -17,6 +24,13 @@ def get_models():
"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,
}
)
+19
View File
@@ -1,3 +1,4 @@
from snek.model.channel_message import ChannelMessageModel
from snek.system.model import BaseModel, ModelField
@@ -9,3 +10,21 @@ class ChannelModel(BaseModel):
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")
+26
View File
@@ -13,3 +13,29 @@ class ChannelMemberModel(BaseModel):
)
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()
+12 -1
View File
@@ -1,8 +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)
+8 -1
View File
@@ -1,7 +1,14 @@
from snek.system.model import BaseModel,ModelField
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
+18 -6
View File
@@ -1,9 +1,21 @@
from snek.system.model import BaseModel,ModelField
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)
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
+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)
+31 -4
View File
@@ -18,10 +18,7 @@ class UserModel(BaseModel):
regex=r"^[a-zA-Z0-9_-+/]+$",
)
color = ModelField(
name ="color",
required=True,
regex=r"^#[0-9a-fA-F]{6}$",
kind=str
name="color", required=True, regex=r"^#[0-9a-fA-F]{6}$", kind=str
)
email = ModelField(
name="email",
@@ -31,3 +28,33 @@ class UserModel(BaseModel):
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);
}
}
});
+13 -4
View File
@@ -4,14 +4,18 @@ 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.drive import DriveService
from snek.service.drive_item import DriveItemService
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):
@@ -26,7 +30,12 @@ def get_services(app):
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(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),
}
)
+28 -21
View File
@@ -1,26 +1,33 @@
from snek.system.service import BaseService
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(
self,
uid=None,
**kwargs):
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
kwargs["uid"] = uid
result = await super().get(**kwargs)
if result:
return result
del kwargs['uid']
kwargs['name'] = uid
del kwargs["uid"]
kwargs["name"] = uid
result = await super().get(**kwargs)
if result:
return result
kwargs['name'] = '#' + uid
kwargs["name"] = "#" + uid
result = await super().get(**kwargs)
if result:
return result
@@ -53,21 +60,18 @@ class ChannelService(BaseService):
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
)
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
)
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):
users = []
async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid,
is_banned=False,
@@ -77,13 +81,16 @@ class ChannelService(BaseService):
user = await self.services.user.get(uid=channel_member["user_uid"])
if user:
yield user
async def get_online_users(self, channel_uid):
users = []
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() < 20:
if (
datetime.fromisoformat(now())
- datetime.fromisoformat(user["last_ping"])
).total_seconds() < 180:
yield user
async def get_for_user(self, user_uid):
+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}.")
+28 -10
View File
@@ -5,6 +5,18 @@ 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,
@@ -16,7 +28,7 @@ class ChannelMemberService(BaseService):
):
model = await self.get(channel_uid=channel_uid, user_uid=user_uid)
if model:
if model['is_banned']:
if model["is_banned"]:
return False
return model
model = await self.new()
@@ -32,25 +44,31 @@ class ChannelMemberService(BaseService):
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 " ,dict(from_user=from_user, to_user=to_user)):
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'])
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']:
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):
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
+62 -23
View File
@@ -1,37 +1,37 @@
from snek.system.service import BaseService
import jinja2
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, 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 = {
}
context = {}
record = model.record
context.update(record)
user = await self.app.services.user.get(uid=user_uid)
context.update(dict(
user_uid=user['uid'],
username=user['username'],
user_nick=user['nick'],
color=user['color']
))
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)
print(ex, flush=True)
if await self.save(model):
if await super().save(model):
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
@@ -41,31 +41,70 @@ class ChannelMessageService(BaseService):
return {}
return {
"uid": message["uid"],
"color": user['color'],
"color": user["color"],
"user_uid": message["user_uid"],
"channel_uid": message["channel_uid"],
"user_nick": user['nick'],
"user_nick": user["nick"],
"message": message["message"],
"created_at": message["created_at"],
"html": message['html'],
"username": user['username']
"html": message["html"],
"username": user["username"],
}
async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):
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",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=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",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):
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",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):
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'])
results.sort(key=lambda x: x["created_at"])
return results
+56 -21
View File
@@ -1,31 +1,66 @@
from snek.system.model import now
from snek.system.service import BaseService
class ChatService(BaseService):
async def send(self,user_uid, channel_uid, message):
async def finalize(self, message_uid):
channel_message = await self.services.channel_message.get(uid=message_uid)
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel['uid'],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user['uid'],
"color": user["color"],
"channel_uid": channel['uid'],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": channel_message["is_final"],
},
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid,
user_uid,
message
channel_uid, user_uid, message,is_final
)
channel_message_uid = channel_message["uid"]
user = await self.services.user.get(uid=user_uid)
await self.services.notification.create_channel_message(channel_message_uid)
sent_to_count = await self.services.socket.broadcast(channel_uid, dict(
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']
))
return sent_to_count
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)
+142 -10
View File
@@ -5,17 +5,149 @@ class DriveService(BaseService):
mapper_name = "drive"
async def get_by_user(self, user_uid):
drives = []
async for model in self.find(user_uid=user_uid):
drives.append(model)
return drives
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
async def get_or_create(self, user_uid):
drives = await self.get_by_user(user_uid=user_uid)
if len(drives) == 0:
model = await self.new()
model['user_uid'] = user_uid
model["user_uid"] = user_uid
model["name"] = name
await self.save(model)
return model
return drives[0]
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"])
+8 -6
View File
@@ -5,14 +5,16 @@ class DriveItemService(BaseService):
mapper_name = "drive_item"
async def create(self, drive_uid, name, path, type_,size):
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['file_type'] = type_
model['file_size'] = size
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}.")
+30 -2
View File
@@ -1,9 +1,24 @@
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
@@ -19,12 +34,22 @@ class NotificationService(BaseService):
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"
@@ -32,6 +57,9 @@ class NotificationService(BaseService):
model["message"] = (
f"New message from {user['nick']} in {channel_member['label']}."
)
if await self.save(model):
return model
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)
+49 -17
View File
@@ -1,8 +1,11 @@
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):
@@ -12,15 +15,15 @@ class SocketService(BaseService):
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 as ex:
print(ex,flush=True)
except Exception:
self.is_connected = False
return True
return self.is_connected
async def close(self):
if not self.is_connected:
@@ -31,43 +34,72 @@ class SocketService(BaseService):
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):
return
if not channel_uid in self.subscriptions:
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))
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,[]):
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):
count = 0
async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):
count += await self.send_to_user(channel_member["user_uid"],message)
return count
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)
+69 -5
View File
@@ -1,3 +1,5 @@
import pathlib
from snek.system import security
from snek.system.service import BaseService
@@ -5,12 +7,15 @@ from snek.system.service import BaseService
class UserService(BaseService):
mapper_name = "user"
async def get_by_username(self, username):
return await self.get(username=username)
async def search(self, query, **kwargs):
query = query.strip().lower()
if not query:
raise []
return []
results = []
async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
results.append(result)
return results
@@ -23,17 +28,76 @@ class UserService(BaseService):
return True
async def save(self, user):
if not user['color']:
user['color'] = await self.services.util.random_light_hex_color()
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["nick"] = username
model['color'] = await self.services.util.random_light_hex_color()
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)
+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
+1 -2
View File
@@ -1,6 +1,5 @@
import random
from snek.system.service import BaseService
@@ -12,4 +11,4 @@ class UtilService(BaseService):
g = random.randint(128, 255)
b = random.randint(128, 255)
return "#{:02x}{:02x}{:02x}".format(r, g, b)
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
+86 -186
View File
@@ -7,16 +7,20 @@
// MIT License
class RESTClient {
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;
if (encodedParams) url += "?" + encodedParams;
const response = await fetch(url, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
const result = await response.json();
@@ -28,9 +32,9 @@ class RESTClient {
async post(url, data) {
const response = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
@@ -43,25 +47,13 @@ class RESTClient {
}
}
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));
}
}
class Chat extends EventHandler {
export class Chat extends EventHandler {
constructor() {
super();
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
this._url =
window.location.hostname === "localhost"
? "ws://localhost/chat.ws"
: "wss://" + window.location.hostname + "/chat.ws";
this._socket = null;
this._waitConnect = null;
this._promises = {};
@@ -92,7 +84,7 @@ class Chat extends EventHandler {
}
generateUniqueId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
return "id-" + Math.random().toString(36).substr(2, 9);
}
call(method, ...args) {
@@ -120,7 +112,7 @@ class Chat extends EventHandler {
this._socket.onclose = () => {
this._waitSocket = null;
this._socket = null;
this.emit('close');
this.emit("close");
};
}
@@ -132,194 +124,94 @@ class Chat extends EventHandler {
}
}
class Socket extends EventHandler {
ws = null;
isConnected = null;
isConnecting = null;
url = null;
connectPromises = [];
ensureTimer = null;
constructor() {
super();
this.url = window.location.hostname === 'localhost' ? 'ws://localhost:8081/rpc.ws' : 'wss://' + window.location.hostname + '/rpc.ws';
this.ensureConnection();
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
}
get client() {
const me = this;
return new Proxy({}, {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
};
},
});
}
ensureConnection() {
if (this.ensureTimer) {
return this.connect();
}
const me = this;
this.ensureTimer = setInterval(() => {
if (me.isConnecting) me.isConnecting = false;
me.connect();
}, 5000);
return this.connect();
}
generateUniqueId() {
return 'id-' + Math.random().toString(36).substr(2, 9);
}
connect() {
const me = this
if (this.isConnected || this.isConnecting) {
return new Promise((resolve) => {
me.connectPromises.push(resolve);
if (!me.isConnecting) resolve(me);
});
}
this.isConnecting = true;
return new Promise((resolve) => {
this.connectPromises.push(resolve);
console.debug("Connecting..");
const ws = new WebSocket(this.url);
ws.onopen = () => {
this.ws = ws;
this.isConnected = true;
this.isConnecting = false;
ws.onmessage = (event) => {
this.onData(JSON.parse(event.data));
};
ws.onclose = () => {
this.onClose();
};
ws.onerror = () => {
this.onClose();
};
this.onConnect()
this.connectPromises.forEach(resolver => resolver(this));
};
});
}
onConnect(){
this.emit("connected")
}
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(data);
}
if (data.callId) {
this.emit(data.callId, data.data);
}
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
this.emit("channel-message", data);
}
}
async sendJson(data) {
await this.connect().then(api => {
api.ws.send(JSON.stringify(data));
});
}
async call(method, ...args) {
const call = {
callId: this.generateUniqueId(),
method,
args,
};
const me = this
return new Promise((resolve) => {
me.addEventListener(call.callId, data => resolve(data));
me.sendJson(call);
});
}
onClose() {
console.info("Connection lost. Reconnecting.");
this.isConnected = false;
this.isConnecting = false;
this.ws.close();
this.ws = null;
this.ensureConnection().then(() => {
console.info("Reconnected.");
});
}
}
class NotificationAudio {
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",
}
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()
new Audio(this.sounds[soundIndex])
.play()
.then(() => {
console.debug("Gave sound notification");
})
.catch(error => {
.catch((error) => {
console.error("Notification failed:", error);
});
});
}
}
class App extends EventHandler {
export class App extends EventHandler {
rest = new RESTClient();
ws = null;
rpc = null;
audio = null;
user = {};
async ping(...args) {
if(this.is_pinging)return false
this.is_pinging = true
await this.rpc.ping(...args);
this.is_pinging = false
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.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")
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.rpc.getUser(null).then(user => {
this.ws.addEventListener("data", (data) => {
if(this._debug){
console.debug(data)
}
});
this.rpc.getUser(null).then((user) => {
me.user = user;
});
}
@@ -327,6 +219,7 @@ class App extends EventHandler {
playSound(index) {
this.audio.play(index);
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
@@ -334,35 +227,42 @@ class App extends EventHandler {
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 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`;
return `${days} ${days > 1 ? "days" : "day"} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
}
return 'just now';
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 => {
promises.push(
this.rpc.getChannels().then((channels) => {
channels.forEach((channel) => {
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
});
}));
}),
);
}
}
}
const app = new App();
export const app = new App();
window.app = app;
+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;
}
}
+287 -37
View File
@@ -1,8 +1,14 @@
@import "shared.css";
* {
margin: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
.gallery {
padding: 50px;
height: auto;
@@ -20,23 +26,28 @@
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 {
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;
@@ -45,8 +56,9 @@ header {
header .logo {
color: #fff;
font-size: 1.5em;
font-weight: bold;
font-size: 1.2em;
color: #fff;
}
header nav a {
@@ -74,18 +86,26 @@ a {
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;
background-color: #1a1a1a;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
background-color: #0f0f0f;
border-bottom: 1px solid #333;
user-select: none;
}
@@ -95,30 +115,44 @@ a {
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.message-list {
flex: 1;
height: 200px;
height: 10px;
padding-bottom: 40px;
overflow-y: auto;
}
.chat-messages {
.chat-messages, .threads {
flex: 1;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 10px;
height: 200px;
background: #1a1a1a;
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;
@@ -126,11 +160,15 @@ a {
}
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
.chat-messages .message {
.chat-messages .message, .threads .thread {
display: flex;
align-items: flex-start;
margin-bottom: 0;
@@ -138,7 +176,7 @@ a {
border-radius: 8px;
}
.chat-messages .message .avatar {
.chat-messages .message .avatar, .threads .thread .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
@@ -152,32 +190,35 @@ a {
margin-right: 15px;
}
.chat-messages .message .message-content {
.chat-messages .message .message-content, .threads .thread .message-content {
flex: 1;
}
.chat-messages .message .message-content .author {
.chat-messages .message .message-content .author, .threads .thread .message-content .author {
font-weight: bold;
color: #f05a28;
margin-bottom: 3px;
}
* {
word-break: break-word;
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 {
}
.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;
hyphens: auto;
}
.message-content {
@@ -187,27 +228,26 @@ hyphens: auto;
.message-content {
img, video, iframe, div {
max-width: 100%;
max-width: 90%;
border-radius: 20px;
}
}
.chat-messages .message .message-content .time {
.chat-messages .message .message-content .time, .threads .thread .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;
}
input[type="text"], .chat-input textarea {
flex: 1;
background-color: #1a1a1a;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
@@ -215,7 +255,6 @@ input[type="text"], .chat-input textarea {
}
.chat-input upload-button {
background-color: #f05a28;
color: white;
border: none;
padding: 10px 15px;
@@ -246,15 +285,21 @@ input[type="text"], .chat-input textarea {
.avatar {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.author, .time {
.author {
display: none;
}
&:not(:hover, :focus-within, :active) .time {
opacity: 0;
}
}
.message.switch-user {
.text img,iframe, video {
.text img, iframe, video {
max-width: 90%;
border-radius: 20px;
}
@@ -262,6 +307,7 @@ input[type="text"], .chat-input textarea {
.avatar {
user-select: none;
opacity: 1;
max-height: unset;
}
.author {
@@ -269,15 +315,16 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:last-child{
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.time {
display: block;
}
opacity: 1;
}
}
/* The entire scrollbar */
::-webkit-scrollbar {
display:none;
display: none;
}
::-webkit-scrollbar-track {
@@ -294,20 +341,22 @@ input[type="text"], .chat-input textarea {
background-color: #555;
}
.chat-messages {
.chat-messages, .threads {
scrollbar-width: none;
scrollbar-color: #888 #f1f1f1;
}
a {
text-decoration:none
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 {
@@ -335,3 +384,204 @@ a {
color: #fff;
}
@keyframes glow {
0% {
box-shadow: 0 0 5px #3498db;
}
50% {
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
}
100% {
box-shadow: 0 0 5px #3498db;
}
}
.glow {
animation: glow 1s;
}
@media only screen and (max-width: 768px) {
header {
top: 0;
left: 0;
text-overflow: ellipsis;
width: 100%;
display: flex;
flex-direction: column;
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
nav {
text-align: right;
flex: 1;
display: block;
width: 100%;
}
}
/*
body {
justify-content: flex-start;
}
header{
position: sticky;
display: block;
.logo {
display:block;
}
}
.chat-input {
position:sticky;
}*/
}
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 12px;
padding: 24px;
background-color: #000; /* Deep black */
color: #f1f1f1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
width: 90%;
max-width: 400px;
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
z-index: 1000;
}
/* Backdrop styling */
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Title and content */
dialog .dialog-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 16px;
color: #fff;
}
dialog .dialog-content {
font-size: 1rem;
color: #ccc;
margin-bottom: 20px;
}
/* Button layout */
dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
dialog .dialog-button {
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background 0.2s ease;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
background-color: #f05a28;
color: white;
}
dialog .dialog-button.primary:hover {
background-color: #f05a28;
}
dialog .dialog-button.secondary {
background-color: #f0a328;
color: #eee;
}
dialog .dialog-button.secondary:hover {
background-color: #f0b84c;
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
+356 -52
View File
@@ -1,69 +1,373 @@
// Written by retoor@molodetz.nl
import { app } from "../app.js";
// This JavaScript class defines a custom HTML element for a chat input widget, featuring a text area and an upload button. It handles user input and triggers events for input changes and message submission.
// Includes standard DOM manipulation methods; no external imports used.
// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License.
class ChatInputElement extends HTMLElement {
_chatWindow = null
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.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
this.changed = false;
}
set chatWindow(value){
this._chatWindow = value
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;
}
get chatWindow(){
return this._chatWindow
}
get channelUid() {
return this.chatWindow.channel.uid
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.container = document.createElement('div');
this.container.classList.add('chat-input');
this.container.innerHTML = `
<textarea placeholder="Type a message..." rows="2"></textarea>
<upload-button></upload-button>
`;
this.textBox = this.container.querySelector('textarea');
this.uploadButton = this.container.querySelector('upload-button');
this.uploadButton.chatInput = this
this.textBox.addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));
const message = e.target.value;
const button = this.container.querySelector('button');
button.disabled = !message;
});
this.textBox.addEventListener('change', (e) => {
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();
this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));
console.error(e.target.value);
});
this.textBox.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (!message) return;
this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));
e.target.value = '';
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.component.appendChild(this.container);
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', ChatInputElement);
customElements.define("chat-input", ChatInputComponent);
+13 -13
View File
@@ -13,11 +13,11 @@
class ChatWindowElement extends HTMLElement {
receivedHistory = false;
channel = null
channel = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('section');
this.attachShadow({ mode: "open" });
this.component = document.createElement("section");
this.app = app;
this.shadowRoot.appendChild(this.component);
}
@@ -27,9 +27,9 @@ class ChatWindowElement extends HTMLElement {
}
async connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/base.css";
this.component.appendChild(link);
this.component.classList.add("chat-area");
@@ -39,7 +39,7 @@ class ChatWindowElement extends HTMLElement {
const chatHeader = document.createElement("div");
chatHeader.classList.add("chat-header");
const chatTitle = document.createElement('h2');
const chatTitle = document.createElement("h2");
chatTitle.classList.add("chat-title");
chatTitle.classList.add("no-select");
chatTitle.innerText = "Loading...";
@@ -51,11 +51,11 @@ class ChatWindowElement extends HTMLElement {
this.channel = channel;
chatTitle.innerText = channel.name;
const channelElement = document.createElement('message-list');
const channelElement = document.createElement("message-list");
channelElement.setAttribute("channel", channel.uid);
this.container.appendChild(channelElement);
const chatInput = document.createElement('chat-input');
const chatInput = document.createElement("chat-input");
chatInput.chatWindow = this;
chatInput.addEventListener("submit", (e) => {
app.rpc.sendMessage(channel.uid, e.detail);
@@ -65,8 +65,8 @@ class ChatWindowElement extends HTMLElement {
this.component.appendChild(this.container);
const messages = await app.rpc.getMessages(channel.uid);
messages.forEach(message => {
if (!message['user_nick']) return;
messages.forEach((message) => {
if (!message["user_nick"]) return;
channelElement.addMessage(message);
});
@@ -74,9 +74,9 @@ class ChatWindowElement extends HTMLElement {
channelElement.addEventListener("message", (message) => {
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
message.detail.element.scrollIntoView({"block": "end"});
message.detail.element.scrollIntoView({ block: "end" });
});
}
}
customElements.define('chat-window', ChatWindowElement);
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);
+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));
}
}
+13 -15
View File
@@ -2,25 +2,22 @@
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
// MIT License
class FancyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: "open" });
this.url = null;
this.type = "button";
this.value = null;
}
connectedCallback() {
this.container = document.createElement('span');
let size = this.getAttribute('size');
this.container = document.createElement("span");
let size = this.getAttribute("size");
console.info({ GG: size });
size = size === 'auto' ? '1%' : '33%';
size = size === "auto" ? "1%" : "33%";
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
@@ -50,19 +47,20 @@ class FancyButton extends HTMLElement {
`;
this.container.appendChild(this.styleElement);
this.buttonElement = document.createElement('button');
this.buttonElement = document.createElement("button");
this.container.appendChild(this.buttonElement);
this.shadowRoot.appendChild(this.container);
this.url = this.getAttribute('url');
this.url = this.getAttribute("url");
this.value = this.getAttribute('value');
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
this.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 == "submit") {
this.closest("form").submit();
return;
}
if (this.url === "/back" || this.url === "/back/") {
+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);
+3 -4
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%;
}
}
+126 -40
View File
@@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
}
set value(val) {
val = val ?? '';
val = val ?? "";
this.inputElement.value = val;
this.inputElement.setAttribute("value", val);
}
@@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.styleElement = document.createElement('style');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
h1 {
@@ -83,6 +83,18 @@ class GenericField extends HTMLElement {
background-color: #1a1a1a;
color: #e6e6e6;
font-size: 1em;
&:focus {
outline: 2px solid #f05a28 !important;
}
&::placeholder {
transition: opacity 0.3s;
}
&:focus::placeholder {
opacity: 0.4;
}
}
button {
@@ -153,10 +165,16 @@ class GenericField extends HTMLElement {
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") {
if (this.field.tag === "button" && this.field.value === "submit") {
this.action = this.field.value;
}
this.inputElement.name = this.field.name;
@@ -164,10 +182,19 @@ class GenericField extends HTMLElement {
const me = this;
this.inputElement.addEventListener("keyup", (e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
me.dispatchEvent(new Event("submit"));
} else if (me.field.value !== e.target.value) {
const event = new CustomEvent("change", { detail: me, bubbles: true });
const event = new CustomEvent("change", {
detail: me,
bubbles: true,
});
me.dispatchEvent(event);
}
});
@@ -177,6 +204,18 @@ class GenericField extends HTMLElement {
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);
}
@@ -184,8 +223,8 @@ class GenericField extends HTMLElement {
return;
}
this.inputElement.setAttribute("type", this.field.type ?? 'input');
this.inputElement.setAttribute("name", this.field.name ?? '');
this.inputElement.setAttribute("type", this.field.type ?? "input");
this.inputElement.setAttribute("name", this.field.name ?? "");
if (this.field.text != null) {
this.inputElement.innerText = this.field.text;
@@ -198,7 +237,9 @@ class GenericField extends HTMLElement {
}
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) {
@@ -211,14 +252,14 @@ class GenericField extends HTMLElement {
this.inputElement.removeAttribute("required");
}
if (!this.footerElement) {
this.footerElement = document.createElement('div');
this.footerElement.style.clear = 'both';
this.footerElement = document.createElement("div");
this.footerElement.style.clear = "both";
this.container.appendChild(this.footerElement);
}
}
}
customElements.define('generic-field', GenericField);
customElements.define("generic-field", GenericField);
class GenericForm extends HTMLElement {
fields = {};
@@ -226,7 +267,7 @@ class GenericForm extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
@@ -238,10 +279,9 @@ class GenericForm extends HTMLElement {
}
div {
background-color: #0f0f0f;
min-width:350px;
border-radius: 10px;
padding: 30px;
width: 400px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
text-align: center;
}
@@ -254,18 +294,29 @@ class GenericForm extends HTMLElement {
}
}`;
this.container = document.createElement('div');
this.container = document.createElement("div");
this.container.appendChild(this.styleElement);
this.container.classList.add("generic-form-container");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
const url = this.getAttribute('url');
const preloadedForm = this.getAttribute("preloaded-structure");
if (preloadedForm) {
try {
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");
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url);
fullUrl.searchParams.set("url", url);
}
this.loadForm(fullUrl.toString());
} else {
@@ -273,25 +324,35 @@ class GenericForm extends HTMLElement {
}
}
async loadForm(url) {
async constructForm(formPayload) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
this.form = await response.json();
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 fieldElement = document.createElement('generic-field');
this.fields[field.name] = fieldElement;
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);
this.container.appendChild(fieldElement);
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;
});
@@ -307,8 +368,33 @@ class GenericForm extends HTMLElement {
}
}
});
});
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;
}
}
});
}
});
} catch (error) {
this.container.textContent = `Error: ${error.message}`;
}
}
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}`;
}
@@ -318,15 +404,15 @@ class GenericForm extends HTMLElement {
const url = this.getAttribute("url");
let response = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({ "action": "validate", "form": this.form })
body: JSON.stringify({ action: "validate", form: this.form }),
});
const form = await response.json();
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
if (!this.form.fields[field.name]) {
return;
}
@@ -340,23 +426,23 @@ class GenericForm extends HTMLElement {
this.fields[field.name].setAttribute("field", field);
this.fields[field.name].updateAttributes();
});
Object.values(form.fields).forEach(field => {
Object.values(form.fields).forEach((field) => {
this.fields[field.name].setErrors(field.errors);
});
return form['is_valid'];
return form["is_valid"];
}
async submit() {
const url = this.getAttribute("url");
const response = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({ "action": "submit", "form": this.form })
body: JSON.stringify({ action: "submit", form: this.form }),
});
return await response.json();
}
}
customElements.define('generic-form', GenericForm);
customElements.define("generic-form", GenericForm);
+9 -7
View File
@@ -9,21 +9,23 @@
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add("html_frame");
let url = this.getAttribute('url');
let url = this.getAttribute("url");
if (!url.startsWith("https")) {
url = "https://" + url;
}
if (url) {
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) {
fullUrl.searchParams.set('url', url);
fullUrl.searchParams.set("url", url);
}
this.loadAndRender(fullUrl.toString());
} else {
@@ -39,7 +41,7 @@ class HTMLFrame extends HTMLElement {
}
const html = await response.text();
if (url.endsWith(".md")) {
const markdownElement = document.createElement('div');
const markdownElement = document.createElement("div");
markdownElement.innerHTML = html;
this.outerHTML = html;
} else {
@@ -51,4 +53,4 @@ class HTMLFrame extends HTMLElement {
}
}
customElements.define('html-frame', HTMLFrame);
customElements.define("html-frame", HTMLFrame);
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+2 -2
View File
@@ -17,12 +17,12 @@
"start_url": "/web.html",
"icons": [
{
"src": "/image/snek1.png",
"src": "/image/snek192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/image/snek1.png",
"src": "/image/snek512.png",
"type": "image/png",
"sizes": "512x512"
}
+9 -9
View File
@@ -12,22 +12,22 @@
class HTMLFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.container = document.createElement('div');
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
connectedCallback() {
this.container.classList.add('html_frame');
const url = this.getAttribute('url');
this.container.classList.add("html_frame");
const url = this.getAttribute("url");
if (url) {
const fullUrl = url.startsWith('/')
const fullUrl = url.startsWith("/")
? window.location.origin + url
: new URL(window.location.origin + '/http-get');
if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);
: new URL(window.location.origin + "/http-get");
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
this.loadAndRender(fullUrl.toString());
} else {
this.container.textContent = 'No source URL!';
this.container.textContent = "No source URL!";
}
}
@@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
}
}
customElements.define('markdown-frame', HTMLFrame);
customElements.define("markdown-frame", HTMLFrame);
+37 -39
View File
@@ -4,7 +4,6 @@
// No external libraries or dependencies are used other than standard web components.
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@@ -13,19 +12,18 @@
//
// 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.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');
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.grid {
padding: 10px;
@@ -48,26 +46,26 @@ class TileGridElement extends HTMLElement {
}
`;
this.component.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('gallery');
this.container = document.createElement("div");
this.container.classList.add("gallery");
this.component.appendChild(this.container);
}
addImage(src) {
const item = document.createElement('img');
const item = document.createElement("img");
item.src = src;
item.classList.add('tile');
item.style.width = '100px';
item.style.height = '100px';
item.classList.add("tile");
item.style.width = "100px";
item.style.height = "100px";
this.container.appendChild(item);
}
addImages(srcs) {
srcs.forEach(src => this.addImage(src));
srcs.forEach((src) => this.addImage(src));
}
addElement(element) {
element.classList.add('tile');
element.classList.add("tile");
this.container.appendChild(element);
}
}
@@ -75,14 +73,14 @@ class TileGridElement extends HTMLElement {
class UploadButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
this.shadowRoot.appendChild(this.component);
window.u = this;
}
get gridSelector() {
return this.getAttribute('grid');
return this.getAttribute("grid");
}
grid = null;
@@ -91,8 +89,8 @@ class UploadButton extends HTMLElement {
}
connectedCallback() {
console.log('connected');
this.styleElement = document.createElement('style');
console.log("connected");
this.styleElement = document.createElement("style");
this.styleElement.textContent = `
.upload-button {
display: flex;
@@ -116,14 +114,14 @@ class UploadButton extends HTMLElement {
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container.classList.add('upload-button');
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/*';
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.addEventListener('change', (e) => {
input.addEventListener("change", (e) => {
const files = e.target.files;
const urls = [];
for (let i = 0; i < files.length; i++) {
@@ -137,39 +135,39 @@ class UploadButton extends HTMLElement {
reader.readAsDataURL(files[i]);
}
});
const label = document.createElement('label');
label.textContent = 'Upload Images';
const label = document.createElement("label");
label.textContent = "Upload Images";
label.appendChild(input);
this.container.appendChild(label);
}
}
customElements.define('upload-button', UploadButton);
customElements.define('tile-grid', TileGridElement);
customElements.define("upload-button", UploadButton);
customElements.define("tile-grid", TileGridElement);
class MeniaUploadElement extends HTMLElement {
constructor(){
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: "open" });
this.component = document.createElement("div");
alert('aaaa');
alert("aaaa");
this.shadowRoot.appendChild(this.component);
}
connectedCallback() {
this.container = document.createElement("div");
this.component.style.height = '100%';
this.component.style.backgroundColor = 'blue';
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.tileElement.style.backgroundColor = "red";
this.tileElement.style.height = "100%";
this.component.appendChild(this.tileElement);
this.uploadButton = document.createElement('upload-button');
this.uploadButton = document.createElement("upload-button");
this.component.appendChild(this.uploadButton);
}
}
customElements.define('menia-upload', MeniaUploadElement);
customElements.define("menia-upload", MeniaUploadElement);
+2 -3
View File
@@ -22,18 +22,17 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
class MessageListManagerElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: "open" });
this.container = document.createElement("div");
this.shadowRoot.appendChild(this.container);
}
async connectedCallback() {
const channels = await app.rpc.getChannels();
channels.forEach(channel => {
channels.forEach((channel) => {
const messageList = document.createElement("message-list");
messageList.setAttribute("channel", channel.uid);
this.container.appendChild(messageList);
+84 -147
View File
@@ -5,166 +5,103 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
class MessageListElement extends HTMLElement {
static get observedAttributes() {
return ["messages"];
}
messages = [];
room = null;
url = null;
container = null;
messageEventSchedule = null;
observer = null;
import { app } from "../app.js";
class MessageList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div');
this.shadowRoot.appendChild(this.component);
}
linkifyText(text) {
const urlRegex = /https?:\/\/[^\s]+/g;
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
}
timeAgo(date1, date2) {
const diffMs = Math.abs(date2 - date1);
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
if (days) {
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
}
if (hours) {
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
}
if (minutes) {
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
}
return 'just now';
}
timeDescription(isoDate) {
const date = new Date(isoDate);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
return timeStr;
}
createElement(message) {
const element = document.createElement("div");
element.dataset.uid = message.uid;
element.dataset.color = message.color;
element.dataset.channel_uid = message.channel_uid;
element.dataset.user_nick = message.user_nick;
element.dataset.created_at = message.created_at;
element.dataset.user_uid = message.user_uid;
element.dataset.message = message.message;
element.classList.add("message");
if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
element.classList.add("switch-user");
}
const avatar = document.createElement("div");
avatar.classList.add("avatar");
avatar.classList.add("no-select");
avatar.style.backgroundColor = message.color;
avatar.style.color = "black";
avatar.innerText = message.user_nick[0];
const messageContent = document.createElement("div");
messageContent.classList.add("message-content");
const author = document.createElement("div");
author.classList.add("author");
author.style.color = message.color;
author.textContent = message.user_nick;
const text = document.createElement("div");
text.classList.add("text");
if (message.html) text.innerHTML = message.html;
const time = document.createElement("div");
time.classList.add("time");
time.dataset.created_at = message.created_at;
time.textContent = this.timeDescription(message.created_at);
messageContent.appendChild(author);
messageContent.appendChild(text);
messageContent.appendChild(time);
element.appendChild(avatar);
element.appendChild(messageContent);
message.element = element;
return element;
}
addMessage(message) {
const obj = new models.Message(
message.uid,
message.channel_uid,
message.user_uid,
message.user_nick,
message.color,
message.message,
message.html,
message.created_at,
message.updated_at
);
const element = this.createElement(obj);
this.messages.push(obj);
this.container.appendChild(element);
this.messageEventSchedule.delay(() => {
this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
app.ws.addEventListener("update_message_text", (data) => {
this.updateMessageText(data.uid, data);
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
return obj;
}
scrollBottom() {
this.container.scrollTop = this.container.scrollHeight;
this.items = [];
}
connectedCallback() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/base.css';
this.component.appendChild(link);
this.component.classList.add("chat-messages");
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
})
this.container = document.createElement('div');
this.component.appendChild(this.container);
}
isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom"));
}
scrollToBottom(force) {
this.scrollTop = this.scrollHeight;
this.messageEventSchedule = new Schedule(500);
this.messages = [];
this.channel_uid = this.getAttribute("channel");
this.querySelector(".message-list-bottom").scrollIntoView();
setTimeout(() => {
app.addEventListener(this.channel_uid, (data) => {
this.addMessage(data);
});
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
},200)
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
this.timeUpdateInterval = setInterval(() => {
this.messages.forEach((message) => {
const newText = this.timeDescription(message.created_at);
if (newText != message.element.innerText) {
message.element.querySelector(".time").innerText = newText;
if (!messageDiv) {
return;
}
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
}
triggerGlow(uid,color) {
app.starField.glowColor(color)
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el) => {
const div = el.closest("a");
if (el.href.indexOf(uid) != -1) {
lastElement = el;
}
});
}, 30000);
if (lastElement) {
lastElement.classList.add("glow");
setTimeout(() => {
lastElement.classList.remove("glow");
}, 1000);
}
}
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = "";
//this.insertAdjacentHTML("beforeend", html);
}
}
customElements.define('message-list', MessageListElement);
customElements.define("message-list", MessageList);
+23 -13
View File
@@ -7,20 +7,30 @@
// MIT License
class MessageModel {
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
this.uid = uid
this.message = message
this.html = html
this.user_uid = user_uid
this.user_nick = user_nick
this.color = color
this.channel_uid = channel_uid
this.created_at = created_at
this.updated_at = updated_at
this.element = null
constructor(
uid,
channel_uid,
user_uid,
user_nick,
color,
message,
html,
created_at,
updated_at,
) {
this.uid = uid;
this.message = message;
this.html = html;
this.user_uid = user_uid;
this.user_nick = user_nick;
this.color = color;
this.channel_uid = channel_uid;
this.created_at = created_at;
this.updated_at = updated_at;
this.element = null;
}
}
const models = {
Message: MessageModel
}
Message: MessageModel,
};
View File
@@ -0,0 +1,8 @@
Promise.withResolvers = Promise.withResolvers || function() {
let resolve, reject;
let promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
+9 -5
View File
@@ -2,9 +2,9 @@ this.onpush = (event) => {
console.log(event.data);
// From here we can write the data to IndexedDB, send it to any open
// windows, display a notification, etc.
};
};
navigator.serviceWorker
navigator.serviceWorker
.register("/service-worker.js")
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.subscribe().then(
@@ -12,13 +12,17 @@ this.onpush = (event) => {
const subscriptionObject = {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: pushSubscription.getKey('p256dh'),
auth: pushSubscription.getKey('auth'),
p256dh: pushSubscription.getKey("p256dh"),
auth: pushSubscription.getKey("auth"),
},
encoding: PushManager.supportedContentEncodings,
/* other app-specific data, such as user identity */
};
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
console.log(
pushSubscription.endpoint,
pushSubscription,
subscriptionObject,
);
// The push subscription details needed by the application
// server are now available, and can be sent to it using,
// for example, the fetch() API.
+179
View File
@@ -0,0 +1,179 @@
:root {
--star-color: white;
--background-color: black;
}
body.day {
--star-color: #444;
--background-color: #e6f0ff;
}
body.night {
--star-color: white;
--background-color: black;
}
body {
margin: 0;
overflow: hidden;
background-color: var(--background-color);
transition: background-color 0.5s;
}
.star {
position: absolute;
border-radius: 50%;
background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out;
z-index: -1;
}
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
#themeToggle {
position: absolute;
top: 10px;
left: 10px;
padding: 8px 12px;
font-size: 14px;
z-index: 1000;
}
.star.special {
box-shadow: 0 0 10px 3px gold;
transform: scale(1.4);
z-index: 10;
}
.star-tooltip {
position: absolute;
font-size: 12px;
color: white;
font-family: sans-serif;
pointer-events: none;
z-index: 9999;
white-space: nowrap;
text-shadow: 1px 1px 2px black;
display: none;
padding: 2px 6px;
}
.star-popup {
position: absolute;
max-width: 300px;
color: #fff;
font-family: sans-serif;
font-size: 14px;
z-index: 10000;
text-shadow: 1px 1px 3px black;
display: none;
padding: 10px;
border-radius: 12px;
}
.star:hover {
cursor: pointer;
}
.star-popup {
position: absolute;
max-width: 300px;
background: white;
color: black;
padding: 15px;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
z-index: 10000;
font-family: sans-serif;
font-size: 14px;
display: none;
}
.star-popup h3 {
margin: 0 0 5px;
font-size: 16px;
}
.star-popup button {
margin-top: 10px;
}
.demo-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3em;
color: white;
font-family: 'Segoe UI', sans-serif;
font-weight: 300;
text-align: center;
text-shadow: 0 0 20px rgba(0,0,0,0.8);
z-index: 9999;
opacity: 0;
transition: opacity 0.6s ease;
max-width: 80vw;
pointer-events: none;
}
@keyframes demoFadeIn {
from {
opacity: 0;
transform: translate(-50%, -60%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes demoPulse {
0% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
30% {
box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
transform: scale(1);
}
}
.demo-highlight {
animation: demoPulse 1.5s ease-out;
font-weight: bold;
position: relative;
z-index: 9999;
}
.star-notify-container {
position: fixed;
top: 50px;
right: 20px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.star-notify {
opacity: 0;
background: transparent;
padding: 5px 10px;
color: white;
font-weight: 300;
text-shadow: 0 0 10px rgba(0,0,0,0.7);
transition: opacity 0.5s ease, transform 0.5s ease;
transform: translateY(-10px);
font-family: 'Segoe UI', sans-serif;
}
+1 -1
View File
@@ -9,7 +9,7 @@
// 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 Schedule {
export class Schedule {
constructor(msDelay = 100) {
this.msDelay = msDelay;
this._once = false;
+36 -1
View File
@@ -1,3 +1,38 @@
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
return permission === "granted";
}
// Subscribe to Push Notifications
async function subscribeUser() {
const registration =
await navigator.serviceWorker.register("/service-worker.js");
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// Send subscription to your backend
await fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
});
}
// Service Worker (service-worker.js)
self.addEventListener("push", (event) => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.message,
icon: data.icon,
});
});
/*
self.addEventListener("install", (event) => {
console.log("Service worker installed");
});
@@ -27,4 +62,4 @@ self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(
"https://snek.molodetz.nl",));
});
});*/
+15
View File
@@ -0,0 +1,15 @@
input, textarea {
&:focus {
outline: 2px solid #f05a28 !important;
}
&::placeholder {
transition: opacity 0.3s;
}
&:focus::placeholder {
opacity: 0.4;
}
}
+151
View File
@@ -0,0 +1,151 @@
import { EventHandler } from "./event-handler.js";
export class Socket extends EventHandler {
/**
* @type {URL}
*/
url;
/**
* @type {WebSocket|null}
*/
ws = null;
/**
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
*/
connection = null;
shouldReconnect = true;
_debug = false;
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
get isConnecting() {
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
}
constructor() {
super();
this.url = new URL("/rpc.ws", window.location.origin);
this.url.protocol = this.url.protocol.replace("http", "ws");
this.connect();
}
connect() {
if (this.ws) {
return this.connection.promise;
}
if (!this.connection || this.connection.resolved) {
this.connection = Promise.withResolvers();
}
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => {
this.connection.resolved = true;
this.connection.resolve(this);
this.emit("connected");
});
this.ws.addEventListener("close", () => {
console.log("Connection closed");
this.disconnect();
});
this.ws.addEventListener("error", (e) => {
console.error("Connection error", e);
this.disconnect();
});
this.ws.addEventListener("message", (e) => {
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
console.error("Binary data not supported");
} else {
try {
this.onData(JSON.parse(e.data));
} catch (e) {
console.error("Failed to parse message", e);
}
}
});
}
onData(data) {
if (data.success !== undefined && !data.success) {
console.error(data);
}
if (data.callId) {
this.emit(data.callId, data.data);
}
if (data.channel_uid) {
this.emit(data.channel_uid, data.data);
if (!data["event"]) this.emit("channel-message", data);
}
this.emit("data", data.data);
if (data["event"]) {
this.emit(data.event, data.data);
}
}
disconnect() {
this.ws?.close();
this.ws = null;
if (this.shouldReconnect)
setTimeout(() => {
console.log("Reconnecting");
this.emit("reconnecting");
return this.connect();
}, 0);
}
_camelToSnake(str) {
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
}
get client() {
const me = this;
return new Proxy(
{},
{
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args);
};
},
},
);
}
generateCallId() {
return self.crypto.randomUUID();
}
async sendJson(data) {
await this.connect().then((api) => {
api.ws.send(JSON.stringify(data));
});
}
async call(method, ...args) {
const call = {
callId: this.generateCallId(),
method,
args,
};
const me = this;
return new Promise((resolve) => {
me.addEventListener(call.callId, (data) => resolve(data));
me.sendJson(call);
});
}
}
+14 -3
View File
@@ -1,15 +1,19 @@
@import "shared.css";
* {
box-sizing: border-box;
}
body {
background-color: #000;
}
.dialog {
background-color: #0f0f0f;
border-radius: 10px;
padding: 30px;
width: 800px;
width: 500px;
margin: 30px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
}
@@ -39,7 +43,6 @@ h2 {
}
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
color: #e6e6e6;
line-height: 1.5;
display: flex;
@@ -53,3 +56,11 @@ div {
text-align: left;
}
@media screen and (max-width: 500px) {
body {
justify-content: flex-start;
}
}
+29 -25
View File
@@ -2,18 +2,17 @@
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
class UploadButtonElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: "open" });
}
chatInput = null
chatInput = null;
async uploadFiles() {
const fileInput = this.container.querySelector('.file-input');
const uploadButton = this.container.querySelector('.upload-button');
const fileInput = this.container.querySelector(".file-input");
const uploadButton = this.container.querySelector(".upload-button");
if (!fileInput.files.length) {
return;
@@ -21,13 +20,13 @@ class UploadButtonElement extends HTMLElement {
const files = fileInput.files;
const formData = new FormData();
formData.append('channel_uid', this.channelUid);
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
formData.append("files[]", files[i]);
}
const request = new XMLHttpRequest();
request.open('POST', '/drive.bin', true);
request.responseType = "json";
request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
request.upload.onprogress = function (event) {
if (event.lengthComputable) {
@@ -35,24 +34,29 @@ class UploadButtonElement extends HTMLElement {
uploadButton.innerText = `${Math.round(percentComplete)}%`;
}
};
const me = this;
request.onload = function () {
if (request.status === 200) {
uploadButton.innerHTML = '📤';
me.dispatchEvent(
new CustomEvent("uploaded", { detail: request.response }),
);
uploadButton.innerHTML = "📤";
} else {
alert('Upload failed');
alert("Upload failed");
}
};
request.onerror = function () {
alert('Error while uploading.');
alert("Error while uploading.");
};
request.send(formData);
const uploadEvent = new Event("upload", {});
this.dispatchEvent(uploadEvent);
}
channelUid = null
channelUid = null;
connectedCallback() {
this.styleElement = document.createElement('style');
this.styleElement = document.createElement("style");
this.styleElement.innerHTML = `
body {
font-family: Arial, sans-serif;
@@ -60,7 +64,7 @@ class UploadButtonElement extends HTMLElement {
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f4f4f4;
background-color: #000000;
}
.upload-container {
position: relative;
@@ -70,7 +74,7 @@ class UploadButtonElement extends HTMLElement {
align-items: center;
justify-content: center;
padding: 10px 20px;
background-color: #f05a28;
background-color: #000000;
color: white;
border: none;
border-radius: 5px;
@@ -87,7 +91,7 @@ class UploadButtonElement extends HTMLElement {
left: 0;
top: 0;
height: 100%;
background: rgba(255, 255, 255, 0.4);
background: #f05a28;;
width: 0%;
}
.hidden-input {
@@ -95,7 +99,7 @@ class UploadButtonElement extends HTMLElement {
}
`;
this.shadowRoot.appendChild(this.styleElement);
this.container = document.createElement('div');
this.container = document.createElement("div");
this.container.innerHTML = `
<div class="upload-container">
<button class="upload-button">
@@ -105,16 +109,16 @@ class UploadButtonElement extends HTMLElement {
</div>
`;
this.shadowRoot.appendChild(this.container);
this.channelUid = this.getAttribute('channel');
this.uploadButton = this.container.querySelector('.upload-button');
this.fileInput = this.container.querySelector('.hidden-input');
this.uploadButton.addEventListener('click', () => {
this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener("click", () => {
this.fileInput.click();
});
this.fileInput.addEventListener('change', () => {
this.fileInput.addEventListener("change", () => {
this.uploadFiles();
});
}
}
customElements.define('upload-button', UploadButtonElement);
customElements.define("upload-button", UploadButtonElement);
+28
View File
@@ -0,0 +1,28 @@
.user-list__item {
display: flex;
margin-bottom: 1em;
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
}
.user-list__item-avatar {
margin-right: 10px;
border-radius: 50%;
overflow: hidden;
width: 40px;
height: 40px;
display: block;
}
.user-list__item-content {
flex: 1;
}
.user-list__item-name {
font-weight: bold;
}
.user-list__item-text {
margin: 5px 0;
}
.user-list__item-time {
font-size: 0.8em;
color: gray;
}
+59
View File
@@ -0,0 +1,59 @@
class UserList extends HTMLElement {
constructor() {
super();
this.users = [];
}
set data(userArray) {
this.users = userArray;
this.render();
}
formatRelativeTime(timestamp) {
const now = new Date();
const msgTime = new Date(timestamp);
const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
} else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
} else {
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
}
}
render() {
this.innerHTML = "";
this.users.forEach((user) => {
const html = `
<div class="user-list__item"
data-uid="${user.uid}"
data-color="${user.color}"
data-user_nick="${user.nick}"
data-created_at="${user.created_at}"
data-user_uid="${user.user_uid}">
<a class="user-list__item-avatar" style="background-color: ${user.color}; color: black;" href="/user/${user.uid}.html">
<img width="40px" height="40px" src="/avatar/${user.uid}.svg" alt="${user.nick}">
</a>
<div class="user-list__item-content">
<div class="user-list__item-name" style="color: ${user.color};">${user.nick}</div>
<div class="user-list__item-time" data-created_at="${user.last_ping}">
<a href="/user/${user.uid}.html">profile</a>
<a href="/channel/${user.uid}.html">dm</a>
</div>
</div>
</div>
`;
this.insertAdjacentHTML("beforeend", html);
});
}
}
customElements.define("user-list", UserList);
+135
View File
@@ -0,0 +1,135 @@
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
class BroadCastSocketView:
def __init__(self):
self.ws = None
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
app = request.app
app['broadcast_clients'].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
print(msg.data)
for client in app['broadcast_clients'] if not client == ws:
await client.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
app['broadcast_clients'].remove(ws)
return ws
app = web.Application()
view = DatasetWebSocketView()
app['broadcast_clients'] = []
app.router.add_get('/db', view.handle)
app.router.add_get('/broadcast', sync_view.handle)
+41 -3
View File
@@ -13,22 +13,54 @@ class Cache:
self.app = app
self.cache = {}
self.max_items = max_items
self.stats = {}
self.enabled = False
self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4
async def get(self, args):
if not self.enabled:
return None
await self.update_stat(args, "get")
try:
self.lru.pop(self.lru.index(args))
except:
print("Cache miss!", args, flush=True)
# print("Cache miss!", args, flush=True)
return None
self.lru.insert(0, args)
while len(self.lru) > self.max_items:
self.cache.pop(self.lru[-1])
self.lru.pop()
print("Cache hit!", args, flush=True)
# print("Cache hit!", args, flush=True)
return self.cache[args]
async def get_stats(self):
all_ = []
for key in self.lru:
all_.append(
{
"key": key,
"set": self.stats[key]["set"],
"get": self.stats[key]["get"],
"delete": self.stats[key]["delete"],
"value": str(self.serialize(self.cache[key].record)),
}
)
return all_
def serialize(self, obj):
cpy = obj.copy()
cpy.pop("created_at", None)
cpy.pop("deleted_at", None)
cpy.pop("email", None)
cpy.pop("password", None)
return cpy
async def update_stat(self, key, action):
if key not in self.stats:
self.stats[key] = {"set": 0, "get": 0, "delete": 0}
self.stats[key][action] = self.stats[key][action] + 1
def json_default(self, value):
# if hasattr(value, "to_json"):
# return value.to_json()
@@ -47,8 +79,11 @@ class Cache:
)
async def set(self, args, result):
if not self.enabled:
return
is_new = args not in self.cache
self.cache[args] = result
await self.update_stat(args, "set")
try:
self.lru.pop(self.lru.index(args))
except (ValueError, IndexError):
@@ -61,9 +96,12 @@ class Cache:
if is_new:
self.version += 1
print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
# print(f"Cache store! {len(self.lru)} items. New version:", self.version, flush=True)
async def delete(self, args):
if not self.enabled:
return
await self.update_stat(args, "delete")
if args in self.cache:
try:
self.lru.pop(self.lru.index(args))
+18 -10
View File
@@ -1,6 +1,6 @@
DEFAULT_LIMIT = 30
import typing
import asyncio
from snek.system.model import BaseModel
@@ -19,8 +19,15 @@ class BaseMapper:
def db(self):
return self.app.db
@property
def loop(self):
return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def new(self):
return self.model_class(mapper=self)
return self.model_class(mapper=self, app=self.app)
@property
def table(self):
@@ -29,7 +36,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid:
kwargs["uid"] = uid
record = self.table.find_one(**kwargs)
record = await self.run_in_executor(self.table.find_one,**kwargs)
if not record:
return None
record = dict(record)
@@ -40,31 +48,31 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs):
return self.table.exists(**kwargs)
return await self.run_in_executor(self.table.exists,**kwargs)
async def count(self, **kwargs) -> int:
return self.table.count(**kwargs)
return await self.run_in_executor(self.table.count, **kwargs)
async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update()
return self.table.upsert(model.record, ["uid"])
return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit
for record in self.table.find(**kwargs):
for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new()
for key, value in record.items():
model[key] = value
yield model
async def query(self, sql, *args):
for record in self.db.query(sql, *args):
for record in await self.run_in_executor(self.db.query,sql, *args):
yield dict(record)
async def delete(self, kwargs=None) -> int:
async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.")
return self.table.delete(**kwargs)
return await self.run_in_executor(self.table.delete, **kwargs)
+17 -4
View File
@@ -1,9 +1,12 @@
# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2
from types import SimpleNamespace
from html import escape
from app.cache import time_cache_async
from mistune import HTMLRenderer, Markdown
from mistune.plugins.formatting import strikethrough
from mistune.plugins.spoiler import spoiler
from mistune.plugins.url import url
from pygments import highlight
from pygments.formatters import html
from pygments.lexers import get_lexer_by_name
@@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):
_allow_harmful_protocols = True
def __init__(self, app, template):
super().__init__(False, True)
self.template = template
self.app = app
@@ -24,7 +29,7 @@ class MarkdownRenderer(HTMLRenderer):
def _escape(self, str):
return str ##escape(str)
def get_lexer(self, lang, default='bash'):
def get_lexer(self, lang, default="bash"):
try:
return get_lexer_by_name(lang, stripall=True)
except:
@@ -34,7 +39,7 @@ class MarkdownRenderer(HTMLRenderer):
if not lang:
lang = info
if not lang:
lang = 'bash'
lang = "bash"
lexer = self.get_lexer(lang)
formatter = html.HtmlFormatter(lineseparator="<br>")
result = highlight(code, lexer, formatter)
@@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):
markdown = Markdown(renderer=renderer)
return markdown(markdown_string)
# def image(self, text: str, url: str, title: Optional[str] = None) -> str:
# src = self.safe_url(url)
# alt = escape(striptags(text))
# s = '<picture><srcset srcset="' + src +'" /><img src="' + src + '" alt="' + alt + '"'
# if title:
# s += ' title="' + safe_entity(title) + '"'
# return s + ' /></picture>'
def render_markdown_sync(app, markdown_string):
renderer = MarkdownRenderer(app, None)
markdown = Markdown(renderer=renderer)
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
return markdown(markdown_string)
+17 -10
View File
@@ -20,25 +20,32 @@ async def no_cors_middleware(request, handler):
async def cors_allow_middleware(request, handler):
response = await handler(request)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND"
)
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
@web.middleware
async def cors_middleware(request, handler):
if request.method == "OPTIONS":
response = web.Response()
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, DELETE, OPTIONS"
async def auth_middleware(request, handler):
request["user"] = None
if request.session.get("uid") and request.session.get("logged_in"):
request["user"] = await request.app.services.user.get(
uid=request.app.session.get("uid")
)
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
return await handler(request)
@web.middleware
async def cors_middleware(request, handler):
if request.headers.get("Allow"):
return await handler(request)
response = await handler(request)
if request.headers.get("Allow"):
return response
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
+5 -1
View File
@@ -145,6 +145,9 @@ class Validator:
raise ValueError(f"Errors: {errors}.")
return True
def __repr__(self):
return str(self.to_json())
@property
async def is_valid(self):
try:
@@ -270,7 +273,8 @@ class BaseModel:
return self
def __init__(self, *args, **kwargs):
self._mapper = None
self._mapper = kwargs.get("mapper")
self.app = kwargs.get("app")
self.fields = {}
for key in dir(self.__class__):
obj = getattr(self.__class__, key)
+5 -2
View File
@@ -1,7 +1,9 @@
import cProfile
import pstats
import sys
from aiohttp import web
profiler = None
import io
@@ -15,10 +17,11 @@ async def profile_middleware(request, handler):
response = await handler(request)
profiler.disable()
stats = pstats.Stats(profiler, stream=sys.stdout)
stats.sort_stats('cumulative')
stats.sort_stats("cumulative")
stats.print_stats()
return response
async def profiler_handler(request):
output = io.StringIO()
stats = pstats.Stats(profiler, stream=output)
@@ -27,6 +30,7 @@ async def profiler_handler(request):
stats.print_stats()
return web.Response(text=output.getvalue())
class Profiler:
def __init__(self):
@@ -40,4 +44,3 @@ class Profiler:
async def __aexit__(self, *args, **kwargs):
self.profiler.disable()
+64 -4
View File
@@ -1,9 +1,55 @@
import hashlib
import uuid
DEFAULT_SALT = b"snekker-de-snek-"
DEFAULT_SALT = "snekker-de-snek-"
DEFAULT_NS = "snekker-de-snek-"
async def hash(data, salt=DEFAULT_SALT):
class UIDNS:
def __init__(self, name: str) -> None:
"""Initialize UIDNS with a name."""
self.name = name
@property
def bytes(self) -> bytes:
"""Return the bytes representation of the name."""
return self.name.encode()
def uid(value: str = None, ns: str = DEFAULT_NS) -> str:
"""Generate a UUID based on the provided value and namespace.
Args:
value (str): The value to generate the UUID from. If None, a new UUID is created.
ns (str): The namespace to use for UUID generation.
Returns:
str: The generated UUID as a string.
"""
try:
ns = ns.decode()
except AttributeError:
pass
if not value:
value = str(uuid.uuid4())
try:
value = value.decode()
except AttributeError:
pass
return str(uuid.uuid5(UIDNS(ns), value))
def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:
"""Hash the given data with the specified salt using SHA-256.
Args:
data (str): The data to hash.
salt (str): The salt to use for hashing.
Returns:
str: The hexadecimal representation of the hashed data.
"""
try:
data = data.encode(errors="ignore")
except AttributeError:
@@ -17,6 +63,20 @@ async def hash(data, salt=DEFAULT_SALT):
obj = hashlib.sha256(salted)
return obj.hexdigest()
async def hash(data: str, salt: str = DEFAULT_SALT) -> str:
return hash_sync(data, salt)
async def verify(string: str, hashed: str):
return await hash(string) == hashed
def verify_sync(string: str, hashed: str) -> bool:
"""Verify if the given string matches the hashed value.
Args:
string (str): The string to verify.
hashed (str): The hashed value to compare against.
Returns:
bool: True if the string matches the hashed value, False otherwise.
"""
return hash_sync(string) == hashed
async def verify(string: str, hashed: str) -> bool:
return verify_sync(string, hashed)
+1 -1
View File
@@ -58,7 +58,7 @@ class BaseService:
raise Exception(f"Couldn't save model. Errors: f{errors}")
async def find(self, **kwargs):
if not "_limit" in kwargs or int(kwargs.get("_limit")) > 30:
if "_limit" not in kwargs or int(kwargs.get("_limit")) > 30:
kwargs["_limit"] = 60
async for model in self.mapper.find(**kwargs):
yield model
+300 -40
View File
@@ -1,15 +1,25 @@
from types import SimpleNamespace
from bs4 import BeautifulSoup
import mimetypes
import re
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs
from app.cache import time_cache
import emoji
import requests
from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension
from jinja2.nodes import Const
emoji.EMOJI_DATA['<img src="/emoji/snek1.gif" />'] = {"en": ":snek1:","status":2,"E":0.6, "alias":[":snek1:"]}
emoji.EMOJI_DATA['<img src="/emoji/snek1.gif" />'] = {
"en": ":snek1:",
"status": 2,
"E": 0.6,
"alias": [":snek1:"],
}
emoji.EMOJI_DATA["""
emoji.EMOJI_DATA[
"""
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣠⣤⡴⠶⢶⣞⣛⣛⡳⣳⠶⣶⡶⢶⢶⣦⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⠶⠚⣫⣴⣬⠐⣶⣿⣿⣏⣽⣿⣿⣇⢿⣯⣿⣿⣻⣿⣿⣾⣮⣹⣿⢶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣴⢾⣻⣽⣾⡇⢡⣿⣿⣇⡟⣿⣿⣿⣼⣿⣿⣿⣿⢸⣿⣟⣿⣷⢻⣿⣿⣿⣷⡽⣧⣹⡻⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -64,66 +74,312 @@ emoji.EMOJI_DATA["""
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣟⣽⣿⣯⣿⣣⣿⣿⣿⣿⣿⡄⢿⣿⣿⣿⣿⣿⣿⡿⠟⣡⣾⣿⣿⣿⣿⣿⣿⡏⣰⣿⣿⡿⢹⡇⣾⠀⣿⣿⣾⣿⡇⠀⠈⢷⢰⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣽⣻⢿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣦⣀⡀⠈⠛⠻⢿⣎⢿⣿⣿⣿⣿⣿⣿⣷⣄⠀⣿⡇⠘⣧⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⢟⣾⣿⣻⡿⣵⣿⣿⣿⣿⣟⣾⣯⠌⠛⠛⠛⠛⠉⠁⢠⣾⣿⣿⣿⣿⣿⣿⡿⠏⣰⣿⣿⡿⠁⢸⡇⢿⠀⣿⣿⣿⣿⡇⠀⠠⠈⠂⢃⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⣻⡿⣿⣿⣿⣿⣿⣿⣿⡇⡄⠾⣲⣾⣿⣌⢿⣿⣿⣿⣿⣿⣿⣿⣧⡙⠇⠀⢻⡄⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠚⠛⠛⠛⠚⠛⠛⠛⠛⠛⠚⠛⠛⠓⠚⠒⠛⠒⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠚⠛⠛⠛⠛⠓⠛⠛⠛⠓⠛⠛⠛⠛⠓⠚⠛⠛⠓⠚⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠚⠛⠛⠛⠛⠛⠛⠓⠛⠚⠛⠛⠛⠛⠓⠛⠛⠛⠛⠛⠛⠛⠛⠛⠒⠚⠚⠁⠀⠀⠀⠀⠀⠀⠀
"""] = {"en": ":a1:","status":2,"E":0.6, "alias":[":a1:"]}
"""
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
def set_link_target_blank(text):
soup = BeautifulSoup(text, 'html.parser')
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
element.attrs['target'] = '_blank'
element.attrs['rel'] = 'noopener noreferrer'
element.attrs['referrerpolicy'] = 'no-referrer'
element.attrs['href'] = element.attrs['href'].strip(".")
element.attrs["target"] = "_blank"
element.attrs["rel"] = "noopener noreferrer"
element.attrs["referrerpolicy"] = "no-referrer"
element.attrs["href"] = element.attrs["href"].strip(".").strip(",")
return str(soup)
def embed_youtube(text):
soup = BeautifulSoup(text, 'html.parser')
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
if element.attrs['href'].startswith("https://www.you") and "?v=" in element.attrs["href"]:
video_name = element.attrs["href"].split("?v=")[1].split("&")[0]
embed_template = f'<iframe width="560" height="315" src="https://www.youtube.com/embed/{video_name}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
element.replace_with(BeautifulSoup(embed_template, 'html.parser'))
# Check if the link is a YouTube link
url = urlparse(element["href"])
if (
url.hostname in ["www.youtu.be", "youtu.be"]
or url.hostname
in [
"www.youtube.com",
"youtube.com",
"www.youtube-nocookie.com",
"youtube-nocookie.com",
]
and any(url.path.startswith(p) for p in ["/watch", "/embed"])
):
queries = parse_qs(url.query)
if "v" in queries:
video_name = queries["v"][0]
else:
video_name = url.path.split("/")[-1]
queries.pop("v", None)
start_time = queries.get("t", None)
if start_time:
queries.pop("t", None)
queries["start"] = []
for t in start_time:
if t.endswith("s"):
t = t[:-1]
if t.isdigit():
queries["start"].append(t)
else:
queries["start"].append(
str(
sum(
int(x) * 60**i
for i, x in enumerate(reversed(t.split(":")))
)
)
)
new_queries = "&".join(
[f"{key}={v}" for key, value in queries.items() for v in value]
)
base_url = (
"youtube-nocookie.com"
if "youtube-nocookie" in url.hostname
else "youtube.com"
)
embed_template = f'<iframe width="560" height="315" style="display:block" src="https://www.{base_url}/embed/{video_name}?{new_queries}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
return str(soup)
def embed_image(text):
soup = BeautifulSoup(text, 'html.parser')
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
for extension in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".ico", ".heif"]:
if extension in element.attrs['href'].lower():
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}" alt="{element.attrs["href"]}" />'
element.replace_with(BeautifulSoup(embed_template, 'html.parser'))
file_mime = mimetypes.guess_type(element.attrs["href"])[0]
if (
file_mime
and file_mime.startswith("image/")
or any(
ext in element.attrs["href"].lower()
for ext in [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
".tiff",
".ico",
".heif",
".heic",
]
)
):
embed_template = f'<img src="{element.attrs["href"]}" title="{element.attrs["href"]}?width=420" alt="{element.attrs["href"]}" />'
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
return str(soup)
def enrich_image_rendering(text):
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("img"):
if element.attrs["src"].startswith("/"):
element.attrs["src"] += "?width=240&height=240"
picture_template = f'''
<picture>
<source srcset="{element.attrs["src"]}" type="{mimetypes.guess_type(element.attrs["src"])[0]}" />
<source srcset="{element.attrs["src"]}&format=webp" type="image/webp" />
<img src="{element.attrs["src"]}&format=png" title="{element.attrs["src"]}" alt="{element.attrs["src"]}" />
</picture>'''
element.replace_with(BeautifulSoup(picture_template, "html.parser"))
return str(soup)
def embed_media(text):
soup = BeautifulSoup(text, 'html.parser')
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all("a"):
for extension in [".mp4", ".mp3", ".wav", ".ogg", ".webm", ".flac", ".aac",".mpg",".avi",".wmv"]:
if extension in element.attrs['href'].lower():
for extension in [
".mp4",
".mp3",
".wav",
".ogg",
".webm",
".flac",
".aac",
".mpg",
".avi",
".wmv",
]:
if extension in element.attrs["href"].lower():
embed_template = f'<video controls> <source src="{element.attrs["href"]}">Your browser does not support the video tag.</video>'
element.replace_with(BeautifulSoup(embed_template, 'html.parser'))
element.replace_with(BeautifulSoup(embed_template, "html.parser"))
return str(soup)
def linkify_https(text):
if not "https://" in text:
if "https://" not in text:
return text
url_pattern = r'(?<!["\'])\bhttps://[^\s<>()]+(?<!\.)'
soup = BeautifulSoup(text, 'html.parser')
soup = BeautifulSoup(text, "html.parser")
for element in soup.find_all(text=True):
parent = element.parent
if parent.name in ['a', 'script', 'style']:
if parent.name in ["a", "script", "style"]:
continue
new_text = re.sub(url_pattern, r'<a href="\g<0>">\g<0></a>', element)
element.replace_with(BeautifulSoup(new_text, 'html.parser'))
element.replace_with(BeautifulSoup(new_text, "html.parser"))
return set_link_target_blank(str(soup))
@time_cache(timeout=60*60)
def get_url_content(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching {url}: {e}")
return None
def embed_url(text):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if page:
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if head_info:
def get_element_options(
elem=None, meta=None, ograph=None, twitter=None
):
if twitter:
tw_tag = head_info.find(
"meta", attrs={"name": "twitter:" + twitter}
) or head_info.find(
"meta", attrs={"property": "twitter:" + twitter}
)
if tw_tag:
return tw_tag.get("content", tw_tag.get("value", None))
if ograph:
og_tag = head_info.find(
"meta", attrs={"property": "og:" + ograph}
) or head_info.find("meta", attrs={"name": "og:" + ograph})
if og_tag:
return og_tag.get("content", og_tag.get("value", None))
if meta:
meta_tag = head_info.find(
"meta", attrs={"name": meta}
) or head_info.find("meta", attrs={"property": meta})
if meta_tag:
return meta_tag.get(
"content", meta_tag.get("value", None)
)
if elem:
elem_tag = head_info.find(elem)
if elem_tag:
return elem_tag.text
return None
original_link_name = element.attrs["href"]
if original_link_name in attachments:
continue
page_name = (
get_element_options("title", "title", "title", "title")
or page_url.netloc
)
page_site = (
get_element_options(None, "site", "site", "site")
or page_url.netloc
)
page_description = get_element_options(
None, "description", "description", "description"
)
page_image = get_element_options(None, "image", "image", "image")
page_image_alt = get_element_options(
None, "image:alt", "image:alt", "image:alt"
)
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
get_element_options(None, None, None, "card")
or "summary_large_image"
)
attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base
attachment = next(attachment_base.children)
attachment.clear()
attachment.attrs["class"] = "embed-url-link"
render_element = attachment
if page_image:
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
render_element.append(
BeautifulSoup(image_template, "html.parser")
)
if page_video:
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
render_element.append(
BeautifulSoup(video_template, "html.parser")
)
if page_audio:
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
render_element.append(
BeautifulSoup(audio_template, "html.parser")
)
description_element_base = BeautifulSoup(
"<span class='description'></span>", "html.parser"
)
description_element = next(description_element_base.children)
description_element.append(
BeautifulSoup(
f'<p class="page-site">{page_site}</p>',
"html.parser",
)
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
)
render_element.append(description_element_base)
for attachment in attachments.values():
soup.append(attachment)
return str(soup)
class EmojiExtension(Extension):
tags = {"emoji"}
@@ -140,8 +396,7 @@ class EmojiExtension(Extension):
).set_lineno(line_number)
def _to_html(self, md_file, caller):
return emoji.emojize(caller(),language='alias')
return emoji.emojize(caller(), language="alias")
class LinkifyExtension(Extension):
@@ -168,8 +423,14 @@ class LinkifyExtension(Extension):
result = embed_media(result)
result = embed_image(result)
result = embed_youtube(result)
result = enrich_image_rendering(result)
result = embed_url(result)
return result
class PythonExtension(Extension):
tags = {"py3"}
@@ -186,26 +447,25 @@ class PythonExtension(Extension):
).set_lineno(line_number)
def _to_html(self, md_file, caller):
def fn(source):
import subprocess
import subprocess
import pathlib
from pathlib import Path
import os
import sys
import requests
def system(command):
if isinstance(command):
command = command.split(" ")
from io import StringIO
stdout = StringIO()
subprocess.run(command,stderr=stdout,stdout=stdout,text=True)
subprocess.run(command, stderr=stdout, stdout=stdout, text=True)
return stdout.getvalue()
to_write = []
def render(text):
global to_write
to_write.append(text)
exec(source)
return "".join(to_write)
return str(fn(caller()))
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import os
try:
import pty
except Exception as ex:
print("You are not able to run a terminal. See error:")
print(ex)
import subprocess
commands = {
"alpine": "docker run -it alpine /bin/sh",
"r": "docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh",
}
class TerminalSession:
def __init__(self, command):
self.master, self.slave = None, None
self.process = None
self.sockets = []
self.history = b""
self.history_size = 1024 * 20
self.command = command
self.start_process(self.command)
def start_process(self, command):
if not self.is_running():
if self.master:
os.close(self.master)
os.close(self.slave)
self.master = None
self.slave = None
self.master, self.slave = pty.openpty()
self.process = subprocess.Popen(
command.split(" "),
stdin=self.slave,
stdout=self.slave,
stderr=self.slave,
bufsize=0,
universal_newlines=True,
)
def is_running(self):
if not self.process:
return False
asyncio.get_event_loop()
return self.process.poll() is None
async def add_websocket(self, ws):
self.start_process(self.command)
asyncio.create_task(self.read_output(ws))
async def read_output(self, ws):
self.sockets.append(ws)
if len(self.sockets) > 1 and self.history:
start = 0
try:
start = self.history.index(b"\n")
except ValueError:
pass
await ws.send_bytes(self.history[start:])
return
loop = asyncio.get_event_loop()
while True:
try:
data = await loop.run_in_executor(None, os.read, self.master, 1024)
if not data:
break
self.history += data
if len(self.history) > self.history_size:
self.history = self.history[: 0 - self.history_size]
try:
for ws in self.sockets:
await ws.send_bytes(data) # Send raw bytes for ANSI support
except:
self.sockets.remove(ws)
except Exception:
await self.close()
break
async def close(self):
print("Terminating process")
if self.process:
self.process.terminate()
self.process = None
if self.master:
os.close(self.master)
os.close(self.slave)
self.master = None
self.slave = None
print("Terminated process")
for ws in self.sockets:
try:
await ws.close()
except Exception:
pass
self.sockets = []
async def write_input(self, data):
try:
data = data.encode()
except AttributeError:
pass
try:
await asyncio.get_event_loop().run_in_executor(
None, os.write, self.master, data
)
except Exception as ex:
print(ex)
await self.close()

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