Compare commits
577
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
4854d40508 | ||
|
|
7dd3133475 | ||
|
|
24dfa39f91 | ||
|
|
7ec65f7c12 | ||
|
|
4f8edef42b | ||
|
|
7818410d55 | ||
|
|
1762191b03 | ||
|
|
2df92e809e | ||
|
|
59a8d32e40 | ||
|
|
c3b3963760 | ||
|
|
a0cd39e3bc | ||
|
|
e48b2258e0 | ||
|
|
35aaf8824f | ||
|
|
76c69ca3ec | ||
|
|
9994225911 | ||
|
|
03f699e448 | ||
|
|
2fd01a5ab7 | ||
|
|
96629113f1 | ||
|
|
973afa0cc2 | ||
|
|
9bc55e771a | ||
|
|
40a292d05e | ||
|
|
bf723db2cc | ||
|
|
46052172b2 | ||
|
|
4f777f0003 | ||
|
|
b5e1ba72d0 | ||
|
|
df120098f9 | ||
|
|
1c1d578db7 | ||
|
|
69352fe0b5 | ||
|
|
fb3980dad0 | ||
|
|
6c21a1e619 | ||
|
|
112c0dc70a | ||
|
|
538a9ce25d | ||
|
|
cdc3d10df5 | ||
|
|
1b150e3e64 | ||
|
|
27dccc324a | ||
|
|
1bb68ab33b | ||
|
|
3c6ea15d47 | ||
|
|
36e663e1ed | ||
|
|
8d2e0381a7 | ||
|
|
f67d7b35f1 | ||
|
|
9ec62f7471 | ||
|
|
6bbbc41360 | ||
|
|
d3844ac7a7 | ||
|
|
9378e95a5b | ||
|
|
5e4c4ce228 | ||
|
|
ffc373db62 | ||
|
|
60266bf0dc | ||
|
|
8393a80022 | ||
|
|
234edf4756 | ||
|
|
5fd401bfb6 | ||
|
|
5663a5f376 | ||
|
|
81327a9e20 | ||
|
|
2a5b9ad276 | ||
|
|
662e71c621 | ||
|
|
636adfd997 | ||
|
|
b94f7a9532 | ||
|
|
f954a34384 | ||
|
|
6a74263606 | ||
|
|
0bf714061c | ||
|
|
e4e2e919c2 | ||
|
|
7fe4289f42 | ||
|
|
3ce866b7da | ||
|
|
43982c16fa | ||
|
|
e33e4196ab | ||
|
|
2c506db4e4 | ||
|
|
9b9d356849 | ||
|
|
30b7871583 | ||
|
|
1c873b7d02 | ||
|
|
539fb262b2 | ||
|
|
3bf09f9083 | ||
|
|
a55d15b635 | ||
|
|
431748c489 | ||
|
|
a0fb214332 | ||
|
|
f0545cbf02 | ||
|
|
a11c336cf5 | ||
|
|
6b083f8b1b | ||
|
|
89afbba165 | ||
|
|
b2a4887e23 | ||
|
|
9f577875f2 | ||
|
|
c322d6147a | ||
|
|
87b6b3362d | ||
|
|
d261f54327 | ||
|
|
59a815f85a | ||
|
|
2e837f96c5 | ||
|
|
00fce6bd68 | ||
|
|
8a85cd7990 | ||
|
|
db5431d77d | ||
|
|
527b010b24 | ||
|
|
e1727caa5f | ||
|
|
c45b61681d | ||
|
|
e09652413f | ||
|
|
0f337e569f | ||
|
|
59a2668c8c | ||
|
|
e79abf4a26 | ||
|
|
53811ca9b2 | ||
|
|
1bed47fbf5 | ||
|
|
ffb22165da | ||
|
|
48c3daf398 | ||
|
|
c0b4ba715c | ||
|
|
00557ec9ea | ||
|
|
c387225a6e | ||
|
|
93462d4c4b | ||
|
|
c5b55399a1 | ||
|
|
79c39828f0 | ||
|
|
dd80f3732b | ||
|
|
25d109beed | ||
|
|
db6d6c0106 | ||
|
|
af1cf4f5ae | ||
|
|
0ea0cd96db | ||
|
|
3858dcbd62 | ||
|
|
b55d74fb12 | ||
|
|
a21e3590ef | ||
|
|
319c1b1b52 | ||
|
|
964a747f42 | ||
|
|
12d2870424 | ||
|
|
015b188d5e | ||
|
|
8cd2f16c5c | ||
|
|
d09055986e | ||
|
|
2e324ff118 | ||
|
|
adad5ed4fe | ||
|
|
ba3152f553 | ||
|
|
a4bea94495 | ||
|
|
ac2f68f93f | ||
|
|
f156a153de | ||
|
|
c48b84bf3a | ||
|
|
01846bf23f | ||
|
|
2c90044185 | ||
|
|
4d7566de9b | ||
|
|
9133b7c3ce | ||
|
|
3412aa0bf0 | ||
|
|
f0591d4939 | ||
|
|
dd108c2004 | ||
|
|
44ac1d2bfa | ||
|
|
1616e4edb9 | ||
|
|
4c34d7eda5 | ||
|
|
17c6124a57 | ||
|
|
95ad49df43 | ||
|
|
7e8ae1632d | ||
|
|
e5d155e124 | ||
|
|
3ae30f1f76 | ||
|
|
adb59eff68 | ||
|
|
e06776d81d | ||
|
|
a5aac9a337 | ||
|
|
ee40c905d4 | ||
|
|
c56bf4fb49 | ||
|
|
5b28044d9e | ||
|
|
e359a8ebe2 | ||
|
|
b867b6ba78 | ||
|
|
ac570d036c | ||
|
|
165dda3210 | ||
|
|
02a0253c1d | ||
|
|
3c0fea6812 | ||
|
|
31062fddbf | ||
|
|
d0dd342e27 | ||
|
|
3c1d5d601f | ||
|
|
49ec99ef01 | ||
|
|
8799662159 | ||
|
|
f6706c165e | ||
|
|
0a3e151377 | ||
|
|
e153811ff3 | ||
|
|
fa59dbc095 | ||
|
|
d6d2f2892b | ||
|
|
707788583a | ||
|
|
b0666a0090 | ||
|
|
0f6eb5c043 | ||
|
|
529ebd23fc | ||
|
|
f7fda2d2c9 | ||
|
|
c709ee11c9 | ||
|
|
6312dfae47 | ||
|
|
061da150f9 | ||
|
|
46a8b612b4 | ||
|
|
1cd0b54656 | ||
|
|
4cc70640e4 | ||
|
|
c36ce17da5 | ||
|
|
3cfb79c8f5 | ||
|
|
d4f5a46409 | ||
|
|
0fa0488385 | ||
|
|
9fb6e64655 | ||
|
|
a3abd854bb | ||
|
|
3b05acffd2 | ||
|
|
bee7d828cd | ||
|
|
8ae9aac045 | ||
|
|
e4b0625799 | ||
|
|
4a770848a6 | ||
|
|
823892a302 | ||
|
|
9b49e659e5 | ||
|
|
ec9af49f29 | ||
|
|
22668f8a72 | ||
|
|
a1840cd034 | ||
|
|
bc65752ea2 | ||
|
|
3594ac1f59 | ||
|
|
0e6fbd523c | ||
|
|
743593affe | ||
|
|
44dd77cec5 | ||
|
|
8fa216c06c | ||
|
|
c529fc87fd | ||
|
|
656ea5f90e | ||
|
|
2582df360a | ||
|
|
6673f7b615 | ||
|
|
94e94cf7ca | ||
|
|
e6bd7aa152 | ||
|
|
087f9c10b4 | ||
|
|
6138cad782 | ||
|
|
c6575d8e52 | ||
|
|
b0a97ad267 | ||
|
|
b31c286a8b | ||
|
|
13f1d2f390 | ||
|
|
d23ed3711a | ||
|
|
d2e2bb8117 | ||
|
|
d71d5da6bc | ||
|
|
75593fd6bb | ||
|
|
c2b8061ac2 | ||
|
|
6948220746 | ||
|
|
d10768403d | ||
|
|
81479e7058 | ||
|
|
99b2beeab0 | ||
|
|
b365afc910 | ||
|
|
27c0abea31 | ||
|
|
e3c997302b | ||
|
|
d24627b35f | ||
|
|
fbd4fa4e66 | ||
|
|
7c52c2d9d5 | ||
|
|
18b1ec20b6 | ||
|
|
87b48af551 | ||
|
|
32e0c959e8 | ||
|
|
2a47c0ba5e | ||
|
|
8058e4a4b0 | ||
|
|
d5917b9454 | ||
|
|
3926b2d837 | ||
|
|
6dca3a96e1 | ||
|
|
886d21999c | ||
|
|
9a923f6bdd | ||
|
|
29139d5d0c | ||
|
|
fe1b3d6d19 | ||
|
|
5fbcadad8b | ||
|
|
9e50b2a6d3 | ||
|
|
6f043d2139 | ||
|
|
73e8779bdc | ||
|
|
6ad3844f03 | ||
|
|
8810679fd8 | ||
|
|
9d5815ed10 | ||
|
|
e6f702a6b4 | ||
|
|
145373399d | ||
|
|
13ce09a5c5 | ||
|
|
bd5bb5ae65 | ||
|
|
71a206a19f | ||
|
|
87c189b3fe | ||
|
|
877ef7970d | ||
|
|
5390b8bdc3 | ||
|
|
af4a70e894 | ||
|
|
529606955a | ||
|
|
1de2c55966 | ||
|
|
dec2281ac8 | ||
|
|
7b32a7eba4 | ||
|
|
c5c160baae | ||
|
|
c2d9af807a | ||
|
|
0bc24e8d2e | ||
|
|
c8461342fd | ||
|
|
78c631e6c7 | ||
|
|
c72b015073 | ||
|
|
604e27ce10 | ||
|
|
6e68408ddf | ||
|
|
013d4adce5 | ||
|
|
7dcabde2ed | ||
|
|
5ba239caa8 | ||
|
|
3960390ec4 | ||
|
|
70db15bf27 | ||
|
|
3c6a0944d6 | ||
|
|
825ece4e78 | ||
|
|
965dc930a9 | ||
|
|
7fa2817f77 | ||
|
|
39fa8fa0cd | ||
|
|
54416ee84f | ||
|
|
287e10d8aa | ||
|
|
2ba28c193a | ||
|
|
819cf8381c | ||
|
|
e62a855409 | ||
|
|
c9c070c497 | ||
|
|
4a8a614adb | ||
|
|
a9663c8170 | ||
|
|
a4d79b06c4 | ||
|
|
752f3df13a | ||
|
|
5b70bb9ea5 | ||
|
|
17c9731b9f | ||
|
|
d8b43dbd08 | ||
|
|
0f950218d6 | ||
|
|
0fad298fc0 | ||
|
|
c55927aa9c | ||
|
|
5cfcafe082 | ||
|
|
c6c2766381 | ||
|
|
c4e3f1fc1f | ||
|
|
91d8f3efd1 | ||
|
|
d9ac1813ba | ||
|
|
fd07001983 | ||
|
|
0266b2a559 | ||
|
|
11b8f0e744 | ||
|
|
24a504e3a7 | ||
|
|
d6061cb45b | ||
|
|
a219ce4d79 | ||
|
|
aedfe9aa94 | ||
|
|
dd5a9a23e8 | ||
|
|
e91ae4584a | ||
|
|
ad7eab9717 | ||
|
|
62aa15a4b4 | ||
|
|
c9113ca095 | ||
|
|
8e195a49e3 | ||
|
|
7c22a70722 | ||
|
|
804556b74d | ||
|
|
6ecd356cc0 | ||
|
|
0a9b66d2f7 | ||
|
|
98c2213a86 | ||
|
|
5a72c8cd83 | ||
|
|
1b72063a5b | ||
|
|
24260f9c37 | ||
|
|
9292e3b8f3 | ||
|
|
095be5892d | ||
|
|
37f6725f2f | ||
|
|
e3afc1ba6e | ||
|
|
a1afaacb2e | ||
|
|
5b78165fb7 | ||
|
|
1807cff67d | ||
|
|
0613f6f54d | ||
|
|
edb35b5707 | ||
|
|
30fe0bfae7 | ||
|
|
8f137cf8e6 | ||
|
|
dd11c8da5a | ||
|
|
4df6055566 | ||
|
|
8d3d7327d7 | ||
|
|
afbf53938b | ||
|
|
e1324e99bf | ||
|
|
d7851e6457 | ||
|
|
84d7b11f24 | ||
|
|
580ec5ab0d | ||
|
|
578c182f27 | ||
|
|
c3c94461c2 | ||
|
|
45e3239cc0 | ||
|
|
e469e27abf | ||
|
|
4620ebb800 | ||
|
|
66b85d146a | ||
|
|
da1be6301c | ||
|
|
162cfe3945 | ||
|
|
e280e87764 | ||
|
|
5af4e5754b | ||
|
|
076fbb30fb | ||
|
|
fbe95d6631 | ||
|
|
8ea41bb592 | ||
|
|
54920e1545 | ||
|
|
a7e0e5a3f8 | ||
|
|
3623286a9d | ||
|
|
821db3cb1a | ||
|
|
e06824f4ec | ||
|
|
2ad5a7b1f4 | ||
|
|
736123c4aa | ||
|
|
60404c6fd3 | ||
|
|
91a21db89b | ||
|
|
c6620ad70a | ||
|
|
ebb520dd4a | ||
|
|
3ccbe8be5c | ||
|
|
c745f60997 | ||
|
|
6c7266f204 | ||
|
|
e21880b4f5 | ||
|
|
2ab4341d00 | ||
|
|
6555e4f826 | ||
|
|
2595594c3a | ||
|
|
bb2b4b61b4 | ||
|
|
c042af8b80 | ||
|
|
6bdc6a7347 | ||
|
|
1b6ebf5008 | ||
|
|
f0d76bd46a | ||
|
|
1a608d8cfb | ||
|
|
2e86ca2a3f | ||
|
|
c77d2fb782 | ||
|
|
bc8a296223 | ||
|
|
6c58f4b26c | ||
|
|
3230c9f93b | ||
|
|
5b03ecda3f | ||
|
|
8b98935d11 | ||
|
|
24cd378c9d | ||
|
|
2fb6be753e | ||
|
|
60efe6ee8a | ||
|
|
104ee27766 | ||
|
|
162f89f9d0 | ||
|
|
95a8a45842 | ||
|
|
3ee7c6d802 | ||
|
|
1792686531 | ||
|
|
aa5703e62f | ||
|
|
33bc695cda | ||
|
|
9e3b9ae326 | ||
|
|
477ca5917a | ||
|
|
8c33bc63d6 | ||
|
|
2e69ac5921 | ||
|
|
f28be3ba55 | ||
|
|
ea4196af8f | ||
|
|
be956a13db | ||
|
|
7bcc67c6d3 | ||
|
|
263595fc7e | ||
|
|
c463dc6dca | ||
|
|
7c4334fe7b | ||
|
|
d1396801c0 | ||
|
|
9c3abdec26 | ||
|
|
53be4b060a | ||
|
|
1f8ebf71d0 | ||
|
|
37da903936 | ||
|
|
3baa6e53df | ||
|
|
b6eba60843 | ||
|
|
2541fc536a | ||
|
|
2cfb8fe308 | ||
|
|
be35a6caf0 | ||
|
|
bfe4b351c1 | ||
|
|
8fe24f711c | ||
|
|
ca463b79a8 | ||
|
|
8a59ddd210 | ||
|
|
3f75c8d5f9 | ||
|
|
087ab1a8a5 | ||
|
|
48891c4386 | ||
|
|
688e7fbf0e | ||
|
|
54c40c6b85 | ||
|
|
49c0f932ab | ||
|
|
0f400a0b6a | ||
|
|
e75836fe87 | ||
|
|
661eba7161 | ||
|
|
a6555dc069 | ||
|
|
cef83aefe7 | ||
|
|
dc2a31abee | ||
|
|
83121f7fa9 | ||
|
|
bfca2bdf73 | ||
|
|
ecb77cf361 | ||
|
|
feb5234b3b | ||
|
|
e7cd397e0f | ||
|
|
78f9679f30 | ||
|
|
a3cec5bce0 | ||
|
|
e2a8efe5ca | ||
|
|
a42c2bdf5d | ||
|
|
afa40ada77 | ||
|
|
bda5cfd52d | ||
|
|
ad4847a78e | ||
|
|
b169fa4792 | ||
|
|
06b539b884 | ||
|
|
a8fea31a32 | ||
|
|
5154811b29 | ||
|
|
60ca3ec791 | ||
|
|
49eb76dc8b | ||
|
|
d7b943dc8c | ||
|
|
8d0d709e18 | ||
|
|
fcb05903f3 | ||
|
|
f291c0f2e4 | ||
|
|
7ca2bc5776 | ||
|
|
9840c8eb03 | ||
|
|
51f1b1d86e | ||
|
|
cfa2af61b8 | ||
|
|
a301e2c5bf | ||
|
|
d4aaa2d66b | ||
|
|
386d9c3aae | ||
|
|
203314b209 | ||
|
|
b6185a95f3 | ||
|
|
6f9adfe67f | ||
|
|
084f8dba20 | ||
|
|
f395d16173 | ||
|
|
b48a901e33 | ||
|
|
fe707dca4e | ||
|
|
f4a5536dcf | ||
|
|
079187e1b4 | ||
|
|
83cc0f6137 | ||
|
|
38a24e9a12 | ||
|
|
23c8ebca73 | ||
|
|
3ae43c84e7 | ||
|
|
7d750db1f8 | ||
|
|
99fc9118b3 | ||
|
|
a06e3f404a | ||
|
|
98d89dbc5f | ||
|
|
e0ed4491b4 | ||
|
|
feeb94c9cf | ||
|
|
928969b8b6 | ||
|
|
4185bb3a69 | ||
|
|
0c68c4e622 | ||
|
|
f6f9968430 | ||
|
|
3796c7c547 | ||
|
|
663ab41510 | ||
|
|
ae5fffe5e0 | ||
|
|
1999a6c8d8 | ||
|
|
7526bcc816 | ||
|
|
efe12644ed | ||
|
|
cfd3e7881e | ||
|
|
495543144d | ||
|
|
2ae2e8450c | ||
|
|
88749ce05c | ||
|
|
10c7232a8f | ||
|
|
8f502af84e | ||
|
|
010f3b03a0 | ||
|
|
bfdfa6c8bb | ||
|
|
0a70e80668 | ||
|
|
cc3b896d2c | ||
|
|
780c178d95 | ||
|
|
5fd03efc30 | ||
|
|
c6f4393166 | ||
|
|
312b9eeeca | ||
|
|
1c53a90e00 | ||
|
|
4de93489ef | ||
|
|
8eff6dd6cb | ||
|
|
15de277a5b | ||
|
|
b06a10f6ec | ||
|
|
03e9003969 | ||
|
|
99cea506de | ||
|
|
a8e3ad1af9 | ||
|
|
5c4c579389 | ||
|
|
3d6e1d2e94 | ||
|
|
20d8d27f03 | ||
|
|
5b88350ff2 | ||
|
|
bca39a612c | ||
|
|
f69586ccf7 | ||
|
|
4fbfe90a13 | ||
|
|
75cb7605cd | ||
|
|
3be25285f4 | ||
|
|
af399e3b72 | ||
|
|
9e89e27c66 | ||
|
|
80f1bbc05e | ||
|
|
82de0f3044 | ||
|
|
b562d17167 | ||
|
|
f9fed90e86 | ||
|
|
d7c003c409 | ||
|
|
561a915e30 | ||
|
|
03c72e85f7 | ||
|
|
1f5dc57d6f | ||
|
|
3e4b6b0062 | ||
|
|
438fad3014 | ||
|
|
030942db09 | ||
|
|
4442f75ec5 | ||
|
|
5f3dac8bc6 | ||
|
|
c558dc2d79 | ||
|
|
931aae5134 | ||
|
|
0e821f8b58 | ||
|
|
75ec590be5 | ||
|
|
41927b7ef4 | ||
|
|
16afbb4e15 | ||
|
|
9f652ece1b | ||
|
|
93b2f6cc41 | ||
|
|
284d38096c | ||
|
|
84e5bac1b9 | ||
|
|
9e94210bc3 | ||
|
|
d69c75c619 | ||
|
|
4da635502b | ||
|
|
7d05bd9da4 | ||
|
|
b2ca373081 | ||
|
|
14c59ba5c0 | ||
|
|
5aee606d5d | ||
|
|
4f1a48c197 | ||
|
|
99d335ac24 | ||
|
|
da72a15068 | ||
|
|
d93d48ef7e | ||
|
|
01d8093e72 | ||
|
|
8e825a90c6 | ||
|
|
f3d12a257e | ||
|
|
374db23669 | ||
|
|
095e30a92f | ||
|
|
26210f8c09 | ||
|
|
188a1e6178 | ||
|
|
2a3e225e1d | ||
|
|
4f71f74574 | ||
|
|
aec9ffd1a1 | ||
|
|
87895a72d3 | ||
|
|
36c69eb8bb | ||
|
|
fb7cb35921 | ||
|
|
4ae846cf8b | ||
|
|
4c601e8333 | ||
|
|
488afdcc74 | ||
|
|
f25feeeca3 | ||
|
|
b4f9ff2c62 | ||
|
|
bb6bcf41d1 | ||
|
|
12ca8e4296 | ||
|
|
352d2deb12 | ||
|
|
5c69e14d7c | ||
|
|
dae877113c | ||
|
|
b56371994f | ||
|
|
9b93403a93 | ||
|
|
18b76ebd5e |
+7
-1
@@ -1,10 +1,16 @@
|
||||
.r_history
|
||||
.vscode
|
||||
.history
|
||||
.resources
|
||||
.backup*
|
||||
docs
|
||||
snek.d*
|
||||
.rcontext.txt
|
||||
*.zip
|
||||
*.db*
|
||||
*.png
|
||||
cache
|
||||
drive
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
+4
-35
@@ -1,41 +1,10 @@
|
||||
FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf
|
||||
FROM python:3.12.8-alpine3.21
|
||||
FROM python:3.14.0a6-bookworm
|
||||
RUN mkdir -p /code
|
||||
WORKDIR /code
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_RUN_HOST=0.0.0.0
|
||||
RUN apk add --no-cache gcc musl-dev linux-headers git
|
||||
|
||||
#WKHTMLTOPDFNEEDS
|
||||
RUN apk add --no-cache \
|
||||
libstdc++ \
|
||||
libx11 \
|
||||
libxrender \
|
||||
libxext \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
fontconfig \
|
||||
freetype \
|
||||
ttf-dejavu \
|
||||
ttf-droid \
|
||||
ttf-freefont \
|
||||
ttf-liberation \
|
||||
# more fonts
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
msttcorefonts-installer \
|
||||
# Install microsoft fonts
|
||||
&& update-ms-fonts \
|
||||
&& fc-cache -f \
|
||||
# Clean up when done
|
||||
&& rm -rf /tmp/* \
|
||||
&& apk del .build-deps
|
||||
COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf
|
||||
COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage
|
||||
COPY setup.cfg setup.cfg
|
||||
RUN apt update && apt install build-essential docker -y
|
||||
COPY pyproject.toml pyproject.toml
|
||||
COPY src src
|
||||
RUN mkdir /drive
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install -e .
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["python","-m","snek.app"]
|
||||
#CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12.8-alpine3.21
|
||||
WORKDIR /code
|
||||
RUN apk add --no-cache gcc musl-dev linux-headers git openssh
|
||||
|
||||
#WKHTMLTOPDFNEEDS
|
||||
|
||||
COPY pyproject.toml pyproject.toml
|
||||
COPY src src
|
||||
COPY ssh_host_key ssh_host_key
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install -e .
|
||||
EXPOSE 2225
|
||||
|
||||
#CMD ["python","-m","snekssh.app"]
|
||||
#CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
|
||||
@@ -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
|
||||
|
||||
@@ -6,12 +6,26 @@ GUNICORN_WORKERS = 1
|
||||
PORT = 8081
|
||||
|
||||
|
||||
install:
|
||||
python3 -m venv .venv
|
||||
$(PIP) install -e .
|
||||
|
||||
shell:
|
||||
.venv/bin/snek shell
|
||||
|
||||
dump:
|
||||
@$(PYTHON) -m snek.dump
|
||||
|
||||
build:
|
||||
|
||||
serve: run
|
||||
|
||||
|
||||
run:
|
||||
$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload
|
||||
.venv/bin/snek serve
|
||||
|
||||
install: ubuntu
|
||||
python3.12 -m venv .venv
|
||||
$(PIP) install -e .
|
||||
|
||||
ubuntu:
|
||||
docker build -f DockerfileUbuntu -t snek_ubuntu .
|
||||
|
||||
|
||||
|
||||
+9
-2
@@ -2,10 +2,17 @@ services:
|
||||
snek:
|
||||
build: .
|
||||
restart: always
|
||||
privileged: true
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./:/code
|
||||
- /media/storage/snek.molodetz.nl/drive:/code/drive
|
||||
- /media/storage/snek.molodetz.nl/drive:/drive
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- PYTHONDONTWRITEBYTECODE=1
|
||||
- PYTHONUNBUFFERED=1
|
||||
entrypoint: ["gunicorn", "-w", "1", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"]
|
||||
#["python","-m","snek.app"]
|
||||
|
||||
#entrypoint: ["python","-m","snek.app"]
|
||||
|
||||
|
||||
+27
-4
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
name = "Snek"
|
||||
version = "1.0.0"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE", content-type="text/markdown" }
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
authors = [
|
||||
{ name = "retoor", email = "retoor@molodetz.nl" }
|
||||
@@ -15,13 +15,36 @@ keywords = ["chat", "snek", "molodetz"]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"mkdocs>=1.4.0",
|
||||
"lxml",
|
||||
"IPython",
|
||||
"shed",
|
||||
"app @ git+https://retoor.molodetz.nl/retoor/app",
|
||||
"app @ git+https://retoor.molodetz.nl/retoor/app.git",
|
||||
"zhurnal @git+https://retoor.molodetz.nl/retoor/zhurnal.git",
|
||||
"beautifulsoup4",
|
||||
"gunicorn",
|
||||
"imgkit",
|
||||
"wkhtmltopdf",
|
||||
"jinja-markdown2",
|
||||
"mistune"
|
||||
"mistune",
|
||||
"aiohttp-session",
|
||||
"cryptography",
|
||||
"requests",
|
||||
"asyncssh",
|
||||
"emoji",
|
||||
"aiofiles",
|
||||
"PyJWT",
|
||||
"multiavatar",
|
||||
"gitpython",
|
||||
'uvloop; platform_system != "Windows"',
|
||||
"humanize",
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"] # <-- this changed
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.*"]
|
||||
|
||||
[project.scripts]
|
||||
snek = "snek.__main__:main"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[metadata]
|
||||
name = snek
|
||||
version = 1.0.0
|
||||
description = Snek chat server
|
||||
author = retoor
|
||||
author_email = retoor@molodetz.nl
|
||||
license = MIT
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
package_dir =
|
||||
= src
|
||||
python_requires = >=3.7
|
||||
install_requires =
|
||||
app @ git+https://retoor.molodetz.nl/retoor/app
|
||||
beautifulsoup4
|
||||
gunicorn
|
||||
imgkit
|
||||
wkhtmltopdf
|
||||
shed
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
snek.serve = snek.server:cli
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import click
|
||||
import uvloop
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
from snek.app import Application
|
||||
from IPython import start_ipython
|
||||
import sqlite3
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
@click.option('--db_path',default="snek.db", help='Database to initialize if not exists.')
|
||||
@click.option('--source',default=None, help='Database to initialize if not exists.')
|
||||
def init(db_path,source):
|
||||
if source and pathlib.Path(source).exists():
|
||||
print(f"Copying {source} to {db_path}")
|
||||
shutil.copy2(source,db_path)
|
||||
print("Database initialized.")
|
||||
return
|
||||
|
||||
if pathlib.Path(db_path).exists():
|
||||
return
|
||||
print(f"Initializing database at {db_path}")
|
||||
db = sqlite3.connect(db_path)
|
||||
db.cursor().executescript(
|
||||
pathlib.Path(__file__).parent.joinpath("schema.sql").read_text()
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
print("Database initialized.")
|
||||
|
||||
@cli.command()
|
||||
@click.option('--port', default=8081, show_default=True, help='Port to run the application on')
|
||||
@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')
|
||||
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
||||
def serve(port, host, db_path):
|
||||
#init(db_path)
|
||||
#asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
web.run_app(
|
||||
Application(db_path=f"sqlite:///{db_path}"), port=port, host=host
|
||||
)
|
||||
|
||||
@cli.command()
|
||||
@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')
|
||||
def shell(db_path):
|
||||
app = Application(db_path=f"sqlite:///{db_path}")
|
||||
start_ipython(argv=[], user_ns={'app': app})
|
||||
|
||||
def main():
|
||||
cli()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+313
-22
@@ -1,22 +1,81 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from snek import snode
|
||||
from snek.view.threads import ThreadsView
|
||||
import json
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp_session import (
|
||||
get_session as session_get,
|
||||
session_middleware,
|
||||
setup as session_setup,
|
||||
)
|
||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
from app.app import Application as BaseApplication
|
||||
from app.cache import time_cache_async
|
||||
from jinja_markdown2 import MarkdownExtension
|
||||
from jinja2 import FileSystemLoader
|
||||
|
||||
from snek.sssh import start_ssh_server
|
||||
from snek.docs.app import Application as DocsApplication
|
||||
from snek.mapper import get_mappers
|
||||
from snek.service import get_services
|
||||
from snek.system import http
|
||||
from snek.system.middleware import cors_middleware
|
||||
from snek.system.cache import Cache
|
||||
from snek.system.markdown import MarkdownExtension
|
||||
from snek.system.middleware import auth_middleware, cors_middleware
|
||||
from snek.system.profiler import profiler_handler
|
||||
from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension
|
||||
from snek.view.about import AboutHTMLView, AboutMDView
|
||||
from snek.view.avatar import AvatarView
|
||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||
from snek.view.drive import DriveView
|
||||
from snek.view.drive import DriveApiView
|
||||
from snek.view.index import IndexView
|
||||
from snek.view.login import LoginView
|
||||
from snek.view.login_form import LoginFormView
|
||||
from snek.view.logout import LogoutView
|
||||
from snek.view.register import RegisterView
|
||||
from snek.view.register_form import RegisterFormView
|
||||
from snek.view.rpc import RPCView
|
||||
from snek.view.repository import RepositoryView
|
||||
from snek.view.search_user import SearchUserView
|
||||
from snek.view.settings.repositories import RepositoriesIndexView
|
||||
from snek.view.settings.repositories import RepositoriesCreateView
|
||||
from snek.view.settings.repositories import RepositoriesUpdateView
|
||||
from snek.view.settings.repositories import RepositoriesDeleteView
|
||||
from snek.view.settings.index import SettingsIndexView
|
||||
from snek.view.settings.profile import SettingsProfileView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
from snek.view.upload import UploadView
|
||||
from snek.view.user import UserView
|
||||
from snek.view.web import WebView
|
||||
from types import SimpleNamespace
|
||||
from snek.view.channel import ChannelAttachmentView
|
||||
from snek.view.channel import ChannelView
|
||||
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
|
||||
from snek.webdav import WebdavApplication
|
||||
from snek.sgit import GitApplication
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def session_middleware(request, handler):
|
||||
setattr(request, "session", await session_get(request))
|
||||
response = await handler(request)
|
||||
return response
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def trailing_slash_middleware(request, handler):
|
||||
if request.path and not request.path.endswith("/"):
|
||||
# Redirect to the same path with a trailing slash
|
||||
raise web.HTTPFound(request.path + "/")
|
||||
return await handler(request)
|
||||
|
||||
|
||||
class Application(BaseApplication):
|
||||
@@ -27,16 +86,108 @@ class Application(BaseApplication):
|
||||
web.normalize_path_middleware(merge_slashes=True),
|
||||
]
|
||||
self.template_path = pathlib.Path(__file__).parent.joinpath("templates")
|
||||
self.static_path = pathlib.Path(__file__).parent.joinpath("static")
|
||||
super().__init__(
|
||||
middlewares=middlewares, template_path=self.template_path, *args, **kwargs
|
||||
middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs
|
||||
)
|
||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||
self.tasks = asyncio.Queue()
|
||||
self._middlewares.append(session_middleware)
|
||||
self._middlewares.append(auth_middleware)
|
||||
self.jinja2_env.add_extension(MarkdownExtension)
|
||||
self.jinja2_env.add_extension(LinkifyExtension)
|
||||
self.jinja2_env.add_extension(PythonExtension)
|
||||
self.jinja2_env.add_extension(EmojiExtension)
|
||||
self.time_start = datetime.now()
|
||||
self.ssh_host = "0.0.0.0"
|
||||
self.ssh_port = 2242
|
||||
self.setup_router()
|
||||
self.setup_services()
|
||||
self.ssh_server = None
|
||||
self.sync_service = None
|
||||
self.executor = None
|
||||
self.cache = Cache(self)
|
||||
self.services = get_services(app=self)
|
||||
self.mappers = get_mappers(app=self)
|
||||
self.broadcast_service = None
|
||||
self.user_availability_service_task = None
|
||||
|
||||
self.on_startup.append(self.prepare_asyncio)
|
||||
self.on_startup.append(self.start_user_availability_service)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
self.on_startup.append(self.prepare_database)
|
||||
|
||||
@property
|
||||
def uptime_seconds(self):
|
||||
return (datetime.now() - self.time_start).total_seconds()
|
||||
|
||||
def setup_services(self):
|
||||
self.services = SimpleNamespace(**get_services(app=self))
|
||||
self.mappers = SimpleNamespace(**get_mappers(app=self))
|
||||
@property
|
||||
def uptime(self):
|
||||
return self._format_uptime(self.uptime_seconds)
|
||||
|
||||
def _format_uptime(self,seconds):
|
||||
seconds = int(seconds)
|
||||
days, seconds = divmod(seconds, 86400)
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours > 0:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
if seconds > 0 or not parts:
|
||||
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
|
||||
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
async def start_user_availability_service(self, app):
|
||||
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
|
||||
async def snode_sync(self, app):
|
||||
self.sync_service = asyncio.create_task(snode.sync_service(app))
|
||||
|
||||
async def start_ssh_server(self, app):
|
||||
app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)
|
||||
if app.ssh_server:
|
||||
asyncio.create_task(app.ssh_server.wait_closed())
|
||||
|
||||
async def prepare_asyncio(self, app):
|
||||
# app.loop = asyncio.get_running_loop()
|
||||
app.executor = ThreadPoolExecutor(max_workers=200)
|
||||
app.loop.set_default_executor(self.executor)
|
||||
|
||||
async def create_task(self, task):
|
||||
await self.tasks.put(task)
|
||||
|
||||
async def task_runner(self):
|
||||
while True:
|
||||
task = await self.tasks.get()
|
||||
self.db.begin()
|
||||
try:
|
||||
await task
|
||||
self.tasks.task_done()
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
self.db.commit()
|
||||
|
||||
async def prepare_database(self, app):
|
||||
self.db.query("PRAGMA journal_mode=WAL")
|
||||
self.db.query("PRAGMA syncnorm=off")
|
||||
|
||||
try:
|
||||
if not self.db["user"].has_index("username"):
|
||||
self.db["user"].create_index("username", unique=True)
|
||||
if not self.db["channel_member"].has_index(["channel_uid", "user_uid"]):
|
||||
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
|
||||
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
|
||||
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
|
||||
except:
|
||||
pass
|
||||
|
||||
await app.services.drive.prepare_all()
|
||||
self.loop.create_task(self.task_runner())
|
||||
|
||||
def setup_router(self):
|
||||
self.router.add_get("/", IndexView)
|
||||
@@ -46,19 +197,60 @@ class Application(BaseApplication):
|
||||
name="static",
|
||||
show_index=True,
|
||||
)
|
||||
self.router.add_view("/profiler.html", profiler_handler)
|
||||
self.router.add_view("/about.html", AboutHTMLView)
|
||||
self.router.add_view("/about.md", AboutMDView)
|
||||
self.router.add_view("/logout.json", LogoutView)
|
||||
self.router.add_view("/logout.html", LogoutView)
|
||||
self.router.add_view("/docs.html", DocsHTMLView)
|
||||
self.router.add_view("/docs.md", DocsMDView)
|
||||
|
||||
self.router.add_view("/status.json", StatusView)
|
||||
self.router.add_view("/settings/index.html", SettingsIndexView)
|
||||
self.router.add_view("/settings/profile.html", SettingsProfileView)
|
||||
self.router.add_view("/settings/profile.json", SettingsProfileView)
|
||||
self.router.add_view("/web.html", WebView)
|
||||
self.router.add_view("/login.html", LoginView)
|
||||
self.router.add_view("/login.json", LoginFormView)
|
||||
self.router.add_view("/login.json", LoginView)
|
||||
self.router.add_view("/register.html", RegisterView)
|
||||
self.router.add_view("/register.json", RegisterFormView)
|
||||
self.router.add_view("/register.json", RegisterView)
|
||||
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
self.router.add_view("/drive.bin", UploadView)
|
||||
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
||||
self.router.add_view("/search-user.html", SearchUserView)
|
||||
self.router.add_view("/search-user.json", SearchUserView)
|
||||
self.router.add_view("/avatar/{uid}.svg", AvatarView)
|
||||
self.router.add_get("/http-get", self.handle_http_get)
|
||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||
|
||||
self.router.add_get("/rpc.ws", RPCView)
|
||||
self.router.add_get("/c/{channel:.*}", ChannelView)
|
||||
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
|
||||
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
|
||||
self.router.add_view("/channel/{channel}.html", WebView)
|
||||
self.router.add_view("/threads.html", ThreadsView)
|
||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||
self.router.add_view("/terminal.html", TerminalView)
|
||||
self.router.add_view("/drive.json", DriveApiView)
|
||||
self.router.add_view("/drive.html", DriveView)
|
||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
self.router.add_view("/stats.json", StatsView)
|
||||
self.router.add_view("/user/{user}.html", UserView)
|
||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||
self.router.add_view("/repository/{username}/{repository}/{path:.*}", RepositoryView)
|
||||
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
|
||||
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
|
||||
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
|
||||
self.router.add_view("/settings/repositories/repository/{name}/delete.html", RepositoriesDeleteView)
|
||||
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
|
||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||
self.router.add_view("/settings/containers/container/{uid}/update.html", ContainersUpdateView)
|
||||
self.router.add_view("/settings/containers/container/{uid}/delete.html", ContainersDeleteView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
self.add_subapp("/webdav", self.webdav)
|
||||
self.add_subapp("/git",self.git)
|
||||
|
||||
#self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
async def handle_test(self, request):
|
||||
|
||||
return await self.render_template(
|
||||
@@ -76,15 +268,114 @@ class Application(BaseApplication):
|
||||
return web.Response(
|
||||
body=path.read_bytes(), headers={"Content-Type": "image/png"}
|
||||
)
|
||||
|
||||
|
||||
#@time_cache_async(60)
|
||||
|
||||
# @time_cache_async(60)
|
||||
async def render_template(self, template, request, context=None):
|
||||
return await super().render_template(template, request, context)
|
||||
channels = []
|
||||
if not context:
|
||||
context = {}
|
||||
context["rid"] = str(uuid.uuid4())
|
||||
if request.session.get("uid"):
|
||||
async for subscribed_channel in self.services.channel_member.find(
|
||||
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
|
||||
):
|
||||
|
||||
parent_object = await subscribed_channel.get_channel()
|
||||
|
||||
item = {}
|
||||
other_user = await self.services.channel_member.get_other_dm_user(
|
||||
subscribed_channel["channel_uid"], request.session.get("uid")
|
||||
)
|
||||
|
||||
last_message = await parent_object.get_last_message()
|
||||
color = None
|
||||
if last_message:
|
||||
last_message_user = await last_message.get_user()
|
||||
color = last_message_user["color"]
|
||||
item["color"] = color
|
||||
item["last_message_on"] = parent_object["last_message_on"]
|
||||
item["is_private"] = parent_object["tag"] == "dm"
|
||||
if other_user:
|
||||
item["name"] = other_user["nick"]
|
||||
item["uid"] = subscribed_channel["channel_uid"]
|
||||
else:
|
||||
item["name"] = subscribed_channel["label"]
|
||||
item["uid"] = subscribed_channel["channel_uid"]
|
||||
item["new_count"] = subscribed_channel["new_count"]
|
||||
|
||||
channels.append(item)
|
||||
|
||||
channels.sort(key=lambda x: x["last_message_on"] or "", reverse=True)
|
||||
if "channels" not in context:
|
||||
context["channels"] = channels
|
||||
if "user" not in context:
|
||||
context["user"] = await self.services.user.get(
|
||||
request.session.get("uid")
|
||||
)
|
||||
|
||||
self.template_path.joinpath(template)
|
||||
|
||||
await self.services.user.get_template_path(request.session.get("uid"))
|
||||
|
||||
self.original_loader = self.jinja2_env.loader
|
||||
|
||||
self.jinja2_env.loader = await self.get_user_template_loader(
|
||||
request.session.get("uid")
|
||||
)
|
||||
|
||||
rendered = await super().render_template(template, request, context)
|
||||
|
||||
self.jinja2_env.loader = self.original_loader
|
||||
|
||||
return rendered
|
||||
|
||||
|
||||
app = Application()
|
||||
async def static_handler(self, request):
|
||||
file_name = request.match_info.get('filename', '')
|
||||
|
||||
paths = []
|
||||
|
||||
uid = request.session.get("uid")
|
||||
if uid:
|
||||
user_static_path = await self.services.user.get_static_path(uid)
|
||||
if user_static_path:
|
||||
paths.append(user_static_path)
|
||||
|
||||
for admin_uid in self.services.user.get_admin_uids():
|
||||
user_static_path = await self.services.user.get_static_path(admin_uid)
|
||||
if user_static_path:
|
||||
paths.append(user_static_path)
|
||||
|
||||
paths.append(self.static_path)
|
||||
|
||||
for path in paths:
|
||||
if pathlib.Path(path).joinpath(file_name).exists():
|
||||
return web.FileResponse(pathlib.Path(path).joinpath(file_name))
|
||||
return web.HTTPNotFound()
|
||||
|
||||
async def get_user_template_loader(self, uid=None):
|
||||
template_paths = []
|
||||
for admin_uid in self.services.user.get_admin_uids():
|
||||
user_template_path = await self.services.user.get_template_path(admin_uid)
|
||||
if user_template_path:
|
||||
template_paths.append(user_template_path)
|
||||
|
||||
if uid:
|
||||
user_template_path = await self.services.user.get_template_path(uid)
|
||||
if user_template_path:
|
||||
template_paths.append(user_template_path)
|
||||
|
||||
|
||||
template_paths.append(self.template_path)
|
||||
return FileSystemLoader(template_paths)
|
||||
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
|
||||
|
||||
async def main():
|
||||
await web._run_app(app, port=8081, host="0.0.0.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
web.run_app(app, port=8081, host="0.0.0.0")
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import pathlib
|
||||
|
||||
from aiohttp import web
|
||||
from app.app import Application as BaseApplication
|
||||
|
||||
from snek.system.markdown import MarkdownExtension
|
||||
|
||||
|
||||
class Application(BaseApplication):
|
||||
|
||||
def __init__(self, path=None, *args, **kwargs):
|
||||
self.path = pathlib.Path(path)
|
||||
template_path = self.path
|
||||
|
||||
super().__init__(template_path=template_path, *args, **kwargs)
|
||||
self.jinja2_env.add_extension(MarkdownExtension)
|
||||
|
||||
self.router.add_get("/{tail:.*}", self.handle_document)
|
||||
|
||||
async def handle_document(self, request):
|
||||
relative_path = request.match_info["tail"].strip("/")
|
||||
if relative_path == "":
|
||||
relative_path = "index.html"
|
||||
document_path = self.path.joinpath(relative_path)
|
||||
if not document_path.exists():
|
||||
return web.Response(
|
||||
status=404,
|
||||
body=b"Resource is not found on this server.",
|
||||
content_type="text/plain",
|
||||
)
|
||||
if document_path.is_dir():
|
||||
document_path = document_path.joinpath("index.html")
|
||||
if not document_path.exists():
|
||||
return web.Response(
|
||||
status=404,
|
||||
body=b"Resource is not found on this server.",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
response = await self.render_template(
|
||||
str(document_path.relative_to(self.path)), request
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "docs/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
{% markdown %}
|
||||
|
||||
# API Documentation
|
||||
|
||||
Currently only some details about the internal API are available.
|
||||
|
||||
## How to create a user
|
||||
```python
|
||||
# Save user to the table named 'user'
|
||||
# Password gets sha256 encrypted with default a salt string
|
||||
# of the snek.system.security module.
|
||||
|
||||
new_user_object = await app.service.user.register(
|
||||
username="retoor",
|
||||
password="retoorded"
|
||||
)
|
||||
```
|
||||
|
||||
## Encrypt string
|
||||
```python
|
||||
from snek.system import security
|
||||
|
||||
# Support for both utf and bytes.
|
||||
var1 = security.encrypt("data")
|
||||
var2 = security.encrypt(b"data")
|
||||
|
||||
# Is correct:
|
||||
assert(var1 == var2)
|
||||
```
|
||||
|
||||
## How to create a basic HTML / Markdown view
|
||||
```python
|
||||
from snek.system.view import BaseView
|
||||
|
||||
class IndexView(BaseView):
|
||||
|
||||
async def get(self):
|
||||
# The render function supports markdown.
|
||||
# It will render with syntax highlighting.
|
||||
# Just use the .md file extension in the file name.
|
||||
return await self.render("index.html")
|
||||
```
|
||||
## How to create a FormView
|
||||
```python
|
||||
from snek.system.view import BaseFormView
|
||||
from snek.form.register import RegisterForm
|
||||
|
||||
class RegisterFormView(BaseFormView):
|
||||
|
||||
form = RegisterForm
|
||||
```
|
||||
## How to register a class view
|
||||
```python
|
||||
app.routes.add_view("/your-page.html", YourViewClass)
|
||||
```
|
||||
|
||||
{% endmarkdown %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,116 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>{{ highlight_styles }}</style>
|
||||
<style>
|
||||
|
||||
* {
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 800px;
|
||||
margin: 30px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.center {
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
html,body,main {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
article {
|
||||
max-width: 100%;
|
||||
width: 60%;
|
||||
background-color: #0f0f0f;
|
||||
padding: 30px;
|
||||
min-height: 100vh;
|
||||
word-break: break-all;
|
||||
}
|
||||
footer {
|
||||
position: fixed;
|
||||
width: 60%;
|
||||
text-align: center;
|
||||
bottom: 0;
|
||||
left: 20%;
|
||||
background-color: #000;
|
||||
}
|
||||
a {
|
||||
color: #f05a28;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
header {
|
||||
|
||||
text-align: left;
|
||||
width: 60%;
|
||||
padding: 30px;
|
||||
}
|
||||
header a {
|
||||
display: inline;
|
||||
|
||||
}
|
||||
div {
|
||||
text-align: left;
|
||||
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<a href="/">Snek</a>
|
||||
<a href="/docs/docs">Docs</a>
|
||||
</header>
|
||||
<article>
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</article>
|
||||
</main>
|
||||
<footer>
|
||||
{% markdown %}
|
||||
[Respository](https://github.com/retoor/snek)
|
||||
{% endmarkdown %}
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "docs/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
{% markdown %}
|
||||
# Form API Javascript
|
||||
|
||||
## Dependencies
|
||||
- generic-form.js
|
||||
|
||||
## Usage
|
||||
It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.
|
||||
```html
|
||||
<generic-form url="/url-to-form-api"></generic-form>
|
||||
```
|
||||
|
||||
{% endmarkdown %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "docs/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
{% markdown %}
|
||||
# Form API Javascript
|
||||
|
||||
## Dependencies
|
||||
- `snek.system.form.Form`
|
||||
- `snek.system.form.HTMLElement`
|
||||
- `snek.system.form.FormInputElement`
|
||||
- `snek.system.form.FormButtonElement`
|
||||
|
||||
## Usage
|
||||
Here is an example with custom validation.
|
||||
This example contains a field that checks if user already exists.
|
||||
If invalid, it adds an error message which automatically invalidates the field.
|
||||
Handling of the error messages will automatically done client side.
|
||||
|
||||
Forms are usaly located in `snek/form/[form name].py`.
|
||||
|
||||
```python
|
||||
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
|
||||
|
||||
class UsernameField(FormInputElement):
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
result = await super().errors
|
||||
if self.value and await self.app.services.user.count(username=self.value):
|
||||
result.append("Username is not available.")
|
||||
return result
|
||||
|
||||
class RegisterForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Register")
|
||||
|
||||
username = UsernameField(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
place_holder="Username",
|
||||
type="text"
|
||||
)
|
||||
email = FormInputElement(
|
||||
name="email",
|
||||
required=False,
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
place_holder="Email address",
|
||||
type="email"
|
||||
)
|
||||
password = FormInputElement(
|
||||
name="password",
|
||||
required=True,
|
||||
regex=r"^[a-zA-Z0-9_.+-]{6,}",
|
||||
type="password",
|
||||
place_holder="Password"
|
||||
)
|
||||
action = FormButtonElement(
|
||||
name="action",
|
||||
value="submit",
|
||||
text="Register",
|
||||
type="button"
|
||||
)
|
||||
```
|
||||
|
||||
## Set data
|
||||
|
||||
```python
|
||||
# The input structure is in same format as output structure.
|
||||
# Output structure is the result of await form.to_json()
|
||||
data = dict(
|
||||
username=dict(value="retoor"),
|
||||
password=dict(value="retoorded")
|
||||
)
|
||||
form.set_user_data(data)
|
||||
|
||||
# Check if form is valid.
|
||||
is_valid = await form.is_valid
|
||||
|
||||
# Convert form to a record (kv pairs) to be used for persistance.
|
||||
# It does contain an filled uid (UUID4) field already to be used as primary key.
|
||||
# Default fields:
|
||||
# - uid (automatically generated, it's an UUID4 wich you can use as private key for database)
|
||||
# - created_at (automatically generated, it's a string representation of UTC locale)
|
||||
# - updated_at (execute await form.updated_at.update() before saving to set value)
|
||||
key_value_values = await form.record
|
||||
```
|
||||
|
||||
{% endmarkdown %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "docs/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
{% markdown %}
|
||||
# Snek
|
||||
|
||||
# Introduction
|
||||
|
||||
Snek is a high customizable chat application.
|
||||
It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!
|
||||
|
||||
This documentation is under construction. Only the form API and the small introduction is a bit documented.
|
||||
|
||||
## Quick API notes
|
||||
[Small introduction / cheatsheet](/docs/docs/api.html)
|
||||
|
||||
## View API
|
||||
With the view classes of Snek you can render HTML and Markdown
|
||||
- [API Python](#)
|
||||
- [API Javascript](#)
|
||||
|
||||
## ORM API
|
||||
Snek's database model is based on Python dataset library.
|
||||
Snek uses a model/mapper architecture build on top of that library.
|
||||
- [API](#)
|
||||
|
||||
## Form API
|
||||
Snek does have his own components for creating and rendering forms.
|
||||
All forms are made server side and client side is generated client side using a HTML component.
|
||||
It's client side only one line to include a form that can validate and submit.
|
||||
Validation is server side using REST. Page won't refresh.
|
||||
[API Python](/docs/docs/form_api_python.html)
|
||||
[API Javascript](/docs/docs/form_api_javascript.html)
|
||||
|
||||
|
||||
{% endmarkdown %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
import asyncio
|
||||
|
||||
from snek.app import app
|
||||
|
||||
|
||||
async def fix_message(message):
|
||||
message = {
|
||||
"uid": message["uid"],
|
||||
"user_uid": message["user_uid"],
|
||||
"text": message["message"],
|
||||
"sent": message["created_at"],
|
||||
}
|
||||
user = await app.services.user.get(uid=message["user_uid"])
|
||||
message["user"] = user and user["username"] or None
|
||||
return (message["user"] or "") + ": " + (message["text"] or "")
|
||||
|
||||
|
||||
async def dump_public_channels():
|
||||
result = []
|
||||
for channel in app.db["channel"].find(
|
||||
is_private=False, is_listed=True, tag="public"
|
||||
):
|
||||
print(f"Dumping channel: {channel['label']}.")
|
||||
result += [
|
||||
await fix_message(record)
|
||||
for record in app.db["channel_message"].find(
|
||||
channel_uid=channel["uid"], order_by="created_at"
|
||||
)
|
||||
]
|
||||
print("Dump succesfull!")
|
||||
print("Converting to json.")
|
||||
print("Converting succesful, now writing to dump.json")
|
||||
with open("dump.txt", "w") as f:
|
||||
f.write("\n\n".join(result))
|
||||
print("Dump written to dump.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(dump_public_channels())
|
||||
+38
-11
@@ -1,24 +1,51 @@
|
||||
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
class AuthField(FormInputElement):
|
||||
|
||||
@property
|
||||
async def errors(self):
|
||||
result = await super().errors
|
||||
if self.model.password.value and self.model.username.value:
|
||||
if not await self.app.services.user.validate_login(
|
||||
self.model.username.value, self.model.password.value
|
||||
):
|
||||
return ["Invalid username or password"]
|
||||
return result
|
||||
|
||||
|
||||
class LoginForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Login")
|
||||
|
||||
username = FormInputElement(
|
||||
name="username",
|
||||
username = AuthField(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
regex=r"^[a-zA-Z0-9_-]+$",
|
||||
place_holder="Username",
|
||||
type="text"
|
||||
type="text",
|
||||
)
|
||||
password = AuthField(
|
||||
name="password",
|
||||
required=True,
|
||||
min_length=1,
|
||||
type="password",
|
||||
place_holder="Password",
|
||||
)
|
||||
password = FormInputElement(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}",type="password",place_holder="Password")
|
||||
|
||||
action = FormButtonElement(
|
||||
name="action",
|
||||
value="submit",
|
||||
text="Login",
|
||||
type="button"
|
||||
name="action", value="submit", text="Login", type="button"
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
async def is_valid(self):
|
||||
return all(
|
||||
[
|
||||
self["username"],
|
||||
self["password"],
|
||||
not await self.username.errors,
|
||||
not await self.password.errors,
|
||||
]
|
||||
)
|
||||
|
||||
+15
-11
@@ -1,4 +1,5 @@
|
||||
from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
class UsernameField(FormInputElement):
|
||||
|
||||
@@ -9,32 +10,35 @@ class UsernameField(FormInputElement):
|
||||
result.append("Username is not available.")
|
||||
return result
|
||||
|
||||
|
||||
class RegisterForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Register")
|
||||
|
||||
username = UsernameField(
|
||||
name="username",
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
regex=r"^[a-zA-Z0-9_-]+$",
|
||||
place_holder="Username",
|
||||
type="text"
|
||||
type="text",
|
||||
)
|
||||
email = FormInputElement(
|
||||
name="email",
|
||||
required=False,
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
place_holder="Email address",
|
||||
type="email"
|
||||
type="email",
|
||||
)
|
||||
password = FormInputElement(
|
||||
name="password",
|
||||
required=True,
|
||||
min_length=1,
|
||||
type="password",
|
||||
place_holder="Password",
|
||||
)
|
||||
password = FormInputElement(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}",type="password",place_holder="Password")
|
||||
|
||||
action = FormButtonElement(
|
||||
name="action",
|
||||
value="submit",
|
||||
text="Register",
|
||||
type="button"
|
||||
name="action", value="submit", text="Register", type="button"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement
|
||||
|
||||
|
||||
class SearchUserForm(Form):
|
||||
|
||||
title = HTMLElement(tag="h1", text="Search user")
|
||||
|
||||
username = FormInputElement(
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
place_holder="Username",
|
||||
)
|
||||
|
||||
action = FormButtonElement(
|
||||
name="action", value="submit", text="Search", type="button"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -1,12 +1,38 @@
|
||||
import functools
|
||||
import functools
|
||||
|
||||
from snek.mapper.channel import ChannelMapper
|
||||
from snek.mapper.channel_member import ChannelMemberMapper
|
||||
from snek.mapper.channel_message import ChannelMessageMapper
|
||||
from snek.mapper.drive import DriveMapper
|
||||
from snek.mapper.drive_item import DriveItemMapper
|
||||
from snek.mapper.notification import NotificationMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.mapper.channel_attachment import ChannelAttachmentMapper
|
||||
from snek.mapper.container import ContainerMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
|
||||
@functools.cache
|
||||
def get_mappers(app=None):
|
||||
return dict(
|
||||
user=UserMapper(app=app)
|
||||
|
||||
return Object(
|
||||
**{
|
||||
"user": UserMapper(app=app),
|
||||
"channel_member": ChannelMemberMapper(app=app),
|
||||
"channel": ChannelMapper(app=app),
|
||||
"channel_message": ChannelMessageMapper(app=app),
|
||||
"notification": NotificationMapper(app=app),
|
||||
"drive_item": DriveItemMapper(app=app),
|
||||
"drive": DriveMapper(app=app),
|
||||
"user_property": UserPropertyMapper(app=app),
|
||||
"repository": RepositoryMapper(app=app),
|
||||
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||
"container": ContainerMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_mapper(name, app=None):
|
||||
return get_mappers(app=app)[name]
|
||||
return get_mappers(app=app)[name]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel import ChannelModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMapper(BaseMapper):
|
||||
table_name = "channel"
|
||||
model_class = ChannelModel
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel_member import ChannelMemberModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMemberMapper(BaseMapper):
|
||||
table_name = "channel_member"
|
||||
model_class = ChannelMemberModel
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class ChannelMessageMapper(BaseMapper):
|
||||
model_class = ChannelMessageModel
|
||||
table_name = "channel_message"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.drive import DriveModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class DriveMapper(BaseMapper):
|
||||
table_name = "drive"
|
||||
model_class = DriveModel
|
||||
@@ -0,0 +1,8 @@
|
||||
from snek.model.drive_item import DriveItemModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class DriveItemMapper(BaseMapper):
|
||||
|
||||
model_class = DriveItemModel
|
||||
table_name = "drive_item"
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.notification import NotificationModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class NotificationMapper(BaseMapper):
|
||||
table_name = "notification"
|
||||
model_class = NotificationModel
|
||||
@@ -0,0 +1,7 @@
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class RepositoryMapper(BaseMapper):
|
||||
model_class = RepositoryModel
|
||||
table_name = "repository"
|
||||
+16
-2
@@ -1,6 +1,20 @@
|
||||
from snek.system.mapper import BaseMapper
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class UserMapper(BaseMapper):
|
||||
table_name = "user"
|
||||
model_class = UserModel
|
||||
model_class = UserModel
|
||||
|
||||
def get_admin_uids(self):
|
||||
try:
|
||||
return [
|
||||
user["uid"]
|
||||
for user in self.db.query(
|
||||
"SELECT uid FROM user WHERE is_admin = :is_admin",
|
||||
{"is_admin": True},
|
||||
)
|
||||
]
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
return []
|
||||
|
||||
@@ -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
|
||||
@@ -1,12 +1,39 @@
|
||||
from snek.model.user import UserModel
|
||||
import functools
|
||||
import functools
|
||||
|
||||
from snek.model.channel import ChannelModel
|
||||
from snek.model.channel_member import ChannelMemberModel
|
||||
|
||||
# from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.model.drive import DriveModel
|
||||
from snek.model.drive_item import DriveItemModel
|
||||
from snek.model.notification import NotificationModel
|
||||
from snek.model.user import UserModel
|
||||
from snek.model.user_property import UserPropertyModel
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.model.channel_attachment import ChannelAttachmentModel
|
||||
from snek.model.container import Container
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_models():
|
||||
return dict(
|
||||
user=UserModel
|
||||
|
||||
return Object(
|
||||
**{
|
||||
"user": UserModel,
|
||||
"channel_member": ChannelMemberModel,
|
||||
"channel": ChannelModel,
|
||||
"channel_message": ChannelMessageModel,
|
||||
"drive_item": DriveItemModel,
|
||||
"drive": DriveModel,
|
||||
"notification": NotificationModel,
|
||||
"user_property": UserPropertyModel,
|
||||
"repository": RepositoryModel,
|
||||
"channel_attachment": ChannelAttachmentModel,
|
||||
"container": Container,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_model(name):
|
||||
return get_models()[name]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from snek.model.channel_message import ChannelMessageModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ChannelModel(BaseModel):
|
||||
label = ModelField(name="label", required=True, kind=str)
|
||||
description = ModelField(name="description", required=False, kind=str)
|
||||
tag = ModelField(name="tag", required=False, kind=str)
|
||||
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
|
||||
is_private = ModelField(name="is_private", required=True, kind=bool, value=False)
|
||||
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
|
||||
index = ModelField(name="index", required=True, kind=int, value=1000)
|
||||
last_message_on = ModelField(name="last_message_on", required=False, kind=str)
|
||||
|
||||
async def get_last_message(self) -> ChannelMessageModel:
|
||||
try:
|
||||
async for model in self.app.services.channel_message.query(
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1",
|
||||
{"channel_uid": self["uid"]},
|
||||
):
|
||||
|
||||
return await self.app.services.channel_message.get(uid=model["uid"])
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def get_members(self):
|
||||
return await self.app.services.channel_member.find(
|
||||
channel_uid=self["uid"], deleted_at=None, is_banned=False
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class ChannelMemberModel(BaseModel):
|
||||
label = ModelField(name="label", required=True, kind=str)
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
is_moderator = ModelField(
|
||||
name="is_moderator", required=True, kind=bool, value=False
|
||||
)
|
||||
is_read_only = ModelField(
|
||||
name="is_read_only", required=True, kind=bool, value=False
|
||||
)
|
||||
is_muted = ModelField(name="is_muted", required=True, kind=bool, value=False)
|
||||
is_banned = ModelField(name="is_banned", required=True, kind=bool, value=False)
|
||||
new_count = ModelField(name="new_count", required=False, kind=int, value=0)
|
||||
|
||||
async def get_user(self):
|
||||
return await self.app.services.user.get(uid=self["user_uid"])
|
||||
|
||||
async def get_channel(self):
|
||||
return await self.app.services.channel.get(uid=self["channel_uid"])
|
||||
|
||||
async def get_name(self):
|
||||
channel = await self.get_channel()
|
||||
if channel["tag"] == "dm":
|
||||
user = await self.get_other_dm_user()
|
||||
return user["nick"]
|
||||
return channel["name"] or self["label"]
|
||||
|
||||
async def get_other_dm_user(self):
|
||||
channel = await self.get_channel()
|
||||
if channel["tag"] != "dm":
|
||||
return None
|
||||
|
||||
async for model in self.app.services.channel_member.find(
|
||||
channel_uid=channel["uid"]
|
||||
):
|
||||
if model["uid"] != self["uid"]:
|
||||
return await self.app.services.user.get(uid=model["user_uid"])
|
||||
return await self.get_user()
|
||||
@@ -0,0 +1,19 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
from datetime import datetime,timezone
|
||||
|
||||
class ChannelMessageModel(BaseModel):
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
message = ModelField(name="message", required=True, kind=str)
|
||||
html = ModelField(name="html", required=False, kind=str)
|
||||
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
|
||||
|
||||
def get_seconds_since_last_update(self):
|
||||
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
|
||||
|
||||
async def get_user(self) -> UserModel:
|
||||
return await self.app.services.user.get(uid=self["user_uid"])
|
||||
|
||||
async def get_channel(self):
|
||||
return await self.app.services.channel.get(uid=self["channel_uid"])
|
||||
@@ -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)
|
||||
@@ -0,0 +1,14 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class DriveModel(BaseModel):
|
||||
|
||||
user_uid = ModelField(name="user_uid", required=True)
|
||||
name = ModelField(name="name", required=False, type=str)
|
||||
|
||||
@property
|
||||
async def items(self):
|
||||
async for drive_item in self.app.services.drive_item.find(
|
||||
drive_uid=self["uid"]
|
||||
):
|
||||
yield drive_item
|
||||
@@ -0,0 +1,21 @@
|
||||
import mimetypes
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class DriveItemModel(BaseModel):
|
||||
drive_uid = ModelField(name="drive_uid", required=True, kind=str)
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
path = ModelField(name="path", required=True, kind=str)
|
||||
file_type = ModelField(name="file_type", required=True, kind=str)
|
||||
file_size = ModelField(name="file_size", required=True, kind=int)
|
||||
is_available = ModelField(name="is_available", required=True, kind=bool, initial_value=True)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
return self["name"].split(".")[-1]
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
mimetype, _ = mimetypes.guess_type(self["name"])
|
||||
return mimetype
|
||||
@@ -0,0 +1,9 @@
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class NotificationModel(BaseModel):
|
||||
object_uid = ModelField(name="object_uid", required=True)
|
||||
object_type = ModelField(name="object_type", required=True)
|
||||
message = ModelField(name="message", required=True)
|
||||
user_uid = ModelField(name="user_uid", required=True)
|
||||
read_at = ModelField(name="is_read", required=True)
|
||||
@@ -0,0 +1,14 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class RepositoryModel(BaseModel):
|
||||
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
|
||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||
|
||||
|
||||
|
||||
+47
-6
@@ -1,19 +1,60 @@
|
||||
from snek.system.model import BaseModel,ModelField
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class UserModel(BaseModel):
|
||||
|
||||
|
||||
username = ModelField(
|
||||
name="username",
|
||||
name="username",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
regex=r"^[a-zA-Z0-9_-+/]+$",
|
||||
)
|
||||
nick = ModelField(
|
||||
name="nick",
|
||||
required=True,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_-+/]+$",
|
||||
)
|
||||
color = ModelField(
|
||||
name="color", required=True, regex=r"^#[0-9a-fA-F]{6}$", kind=str
|
||||
)
|
||||
email = ModelField(
|
||||
name="email",
|
||||
required=False,
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
|
||||
regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
)
|
||||
password = ModelField(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}")
|
||||
password = ModelField(name="password", required=True, min_length=1)
|
||||
|
||||
last_ping = ModelField(name="last_ping", required=False, kind=str)
|
||||
|
||||
is_admin = ModelField(name="is_admin", required=False, kind=bool)
|
||||
|
||||
async def get_property(self, name):
|
||||
prop = await self.app.services.user_property.find_one(
|
||||
user_uid=self["uid"], name=name
|
||||
)
|
||||
if prop:
|
||||
return prop["value"]
|
||||
|
||||
async def has_property(self, name):
|
||||
return await self.app.services.user_property.exists(
|
||||
user_uid=self["uid"], name=name
|
||||
)
|
||||
|
||||
async def set_property(self, name, value):
|
||||
if not await self.has_property(name):
|
||||
await self.app.services.user_property.insert(
|
||||
user_uid=self["uid"], name=name, value=value
|
||||
)
|
||||
else:
|
||||
await self.app.services.user_property.update(
|
||||
user_uid=self["uid"], name=name, value=value
|
||||
)
|
||||
|
||||
async def get_channel_members(self):
|
||||
async for channel_member in self.app.services.channel_member.find(
|
||||
user_uid=self["uid"], is_banned=False, deleted_at=None
|
||||
):
|
||||
yield channel_member
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,44 @@
|
||||
from snek.service.user import UserService
|
||||
import functools
|
||||
import functools
|
||||
|
||||
from snek.service.channel import ChannelService
|
||||
from snek.service.channel_member import ChannelMemberService
|
||||
from snek.service.channel_message import ChannelMessageService
|
||||
from snek.service.chat import ChatService
|
||||
from snek.service.drive import DriveService
|
||||
from snek.service.drive_item import DriveItemService
|
||||
from snek.service.notification import NotificationService
|
||||
from snek.service.socket import SocketService
|
||||
from snek.service.user import UserService
|
||||
from snek.service.user_property import UserPropertyService
|
||||
from snek.service.util import UtilService
|
||||
from snek.service.repository import RepositoryService
|
||||
from snek.service.channel_attachment import ChannelAttachmentService
|
||||
from snek.service.container import ContainerService
|
||||
from snek.system.object import Object
|
||||
from snek.service.db import DBService
|
||||
|
||||
@functools.cache
|
||||
def get_services(app):
|
||||
|
||||
return dict(
|
||||
user = UserService(app=app)
|
||||
|
||||
return Object(
|
||||
**{
|
||||
"user": UserService(app=app),
|
||||
"channel_member": ChannelMemberService(app=app),
|
||||
"channel": ChannelService(app=app),
|
||||
"channel_message": ChannelMessageService(app=app),
|
||||
"chat": ChatService(app=app),
|
||||
"socket": SocketService(app=app),
|
||||
"notification": NotificationService(app=app),
|
||||
"util": UtilService(app=app),
|
||||
"drive": DriveService(app=app),
|
||||
"drive_item": DriveItemService(app=app),
|
||||
"user_property": UserPropertyService(app=app),
|
||||
"repository": RepositoryService(app=app),
|
||||
"db": DBService(app=app),
|
||||
"channel_attachment": ChannelAttachmentService(app=app),
|
||||
"container": ContainerService(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_service(name, app=None):
|
||||
return get_services(app=app)[name]
|
||||
return get_services(app=app)[name]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import datetime
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
import pathlib
|
||||
|
||||
class ChannelService(BaseService):
|
||||
mapper_name = "channel"
|
||||
|
||||
async def get_attachment_folder(self, channel_uid,ensure=False):
|
||||
path = pathlib.Path(f"./drive/{channel_uid}/attachments")
|
||||
if ensure:
|
||||
path.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
return path
|
||||
|
||||
async def get(self, uid=None, **kwargs):
|
||||
if uid:
|
||||
kwargs["uid"] = uid
|
||||
result = await super().get(**kwargs)
|
||||
if result:
|
||||
return result
|
||||
del kwargs["uid"]
|
||||
kwargs["name"] = uid
|
||||
result = await super().get(**kwargs)
|
||||
if result:
|
||||
return result
|
||||
kwargs["name"] = "#" + uid
|
||||
result = await super().get(**kwargs)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
return await super().get(**kwargs)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
label,
|
||||
created_by_uid,
|
||||
description=None,
|
||||
tag=None,
|
||||
is_private=False,
|
||||
is_listed=True,
|
||||
):
|
||||
if label[0] != "#" and is_listed:
|
||||
label = f"#{label}"
|
||||
count = await self.count(deleted_at=None)
|
||||
if not tag and not count:
|
||||
tag = "public"
|
||||
model = await self.new()
|
||||
model["label"] = label
|
||||
model["description"] = description
|
||||
model["tag"] = tag
|
||||
model["created_by_uid"] = created_by_uid
|
||||
model["is_private"] = is_private
|
||||
model["is_listed"] = is_listed
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel: {model.errors}.")
|
||||
|
||||
async def get_dm(self, user1, user2):
|
||||
channel_member = await self.services.channel_member.get_dm(user1, user2)
|
||||
if channel_member:
|
||||
return await self.get(uid=channel_member["channel_uid"])
|
||||
channel = await self.create("DM", user1, tag="dm")
|
||||
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
|
||||
return channel
|
||||
|
||||
async def get_recent_users(self, channel_uid):
|
||||
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
|
||||
yield user
|
||||
|
||||
async def get_users(self, channel_uid):
|
||||
async for channel_member in self.services.channel_member.find(
|
||||
channel_uid=channel_uid,
|
||||
is_banned=False,
|
||||
is_muted=False,
|
||||
deleted_at=None,
|
||||
):
|
||||
user = await self.services.user.get(uid=channel_member["user_uid"])
|
||||
if user:
|
||||
yield user
|
||||
|
||||
async def get_online_users(self, channel_uid):
|
||||
async for user in self.get_users(channel_uid):
|
||||
if not user["last_ping"]:
|
||||
continue
|
||||
|
||||
if (
|
||||
datetime.fromisoformat(now())
|
||||
- datetime.fromisoformat(user["last_ping"])
|
||||
).total_seconds() < 180:
|
||||
yield user
|
||||
|
||||
async def get_for_user(self, user_uid):
|
||||
async for channel_member in self.services.channel_member.find(
|
||||
user_uid=user_uid,
|
||||
is_banned=False,
|
||||
deleted_at=None,
|
||||
):
|
||||
channel = await self.get(uid=channel_member["channel_uid"])
|
||||
yield channel
|
||||
|
||||
async def ensure_public_channel(self, created_by_uid):
|
||||
model = await self.get(is_listed=True, tag="public")
|
||||
is_moderator = False
|
||||
if not model:
|
||||
is_moderator = True
|
||||
model = await self.create(
|
||||
"public", created_by_uid=created_by_uid, is_listed=True, tag="public"
|
||||
)
|
||||
await self.app.services.channel_member.create(
|
||||
model["uid"],
|
||||
created_by_uid,
|
||||
is_moderator=is_moderator,
|
||||
is_read_only=False,
|
||||
is_muted=False,
|
||||
is_banned=False,
|
||||
)
|
||||
return model
|
||||
@@ -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}.")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChannelMemberService(BaseService):
|
||||
|
||||
mapper_name = "channel_member"
|
||||
|
||||
async def mark_as_read(self, channel_uid, user_uid):
|
||||
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
|
||||
channel_member["new_count"] = 0
|
||||
return await self.save(channel_member)
|
||||
|
||||
async def get_user_uids(self, channel_uid):
|
||||
async for model in self.mapper.query(
|
||||
"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid",
|
||||
{"channel_uid": channel_uid},
|
||||
):
|
||||
yield model["user_uid"]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
channel_uid,
|
||||
user_uid,
|
||||
is_moderator=False,
|
||||
is_read_only=False,
|
||||
is_muted=False,
|
||||
is_banned=False,
|
||||
):
|
||||
model = await self.get(channel_uid=channel_uid, user_uid=user_uid)
|
||||
if model:
|
||||
if model["is_banned"]:
|
||||
return False
|
||||
return model
|
||||
model = await self.new()
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
model["label"] = channel["label"]
|
||||
model["channel_uid"] = channel_uid
|
||||
model["user_uid"] = user_uid
|
||||
model["is_moderator"] = is_moderator
|
||||
model["is_read_only"] = is_read_only
|
||||
model["is_muted"] = is_muted
|
||||
model["is_banned"] = is_banned
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel member: {model.errors}.")
|
||||
|
||||
async def get_dm(self, from_user, to_user):
|
||||
async for model in self.query(
|
||||
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user) WHERE channel_member.user_uid=:from_user ",
|
||||
{"from_user": from_user, "to_user": to_user},
|
||||
):
|
||||
return model
|
||||
if not from_user == to_user:
|
||||
return None
|
||||
async for model in self.query(
|
||||
"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL) WHERE channel_member.user_uid=:from_user ",
|
||||
{"from_user": from_user, "to_user": to_user},
|
||||
):
|
||||
|
||||
return model
|
||||
|
||||
async def get_other_dm_user(self, channel_uid, user_uid):
|
||||
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
|
||||
channel = await self.services.channel.get(uid=channel_member["channel_uid"])
|
||||
if channel["tag"] != "dm":
|
||||
return None
|
||||
async for model in self.services.channel_member.find(channel_uid=channel_uid):
|
||||
if model["uid"] != channel_member["uid"]:
|
||||
return await self.services.user.get(uid=model["user_uid"])
|
||||
|
||||
async def create_dm(self, channel_uid, from_user_uid, to_user_uid):
|
||||
result = await self.create(channel_uid, from_user_uid)
|
||||
await self.create(channel_uid, to_user_uid)
|
||||
return result
|
||||
@@ -0,0 +1,110 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChannelMessageService(BaseService):
|
||||
mapper_name = "channel_message"
|
||||
|
||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||
model = await self.new()
|
||||
|
||||
model["channel_uid"] = channel_uid
|
||||
model["user_uid"] = user_uid
|
||||
model["message"] = message
|
||||
model['is_final'] = is_final
|
||||
|
||||
context = {}
|
||||
|
||||
record = model.record
|
||||
context.update(record)
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
context.update(
|
||||
{
|
||||
"user_uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"user_nick": user["nick"],
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
|
||||
if await super().save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
async def to_extended_dict(self, message):
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
if not user:
|
||||
return {}
|
||||
return {
|
||||
"uid": message["uid"],
|
||||
"color": user["color"],
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_uid": message["channel_uid"],
|
||||
"user_nick": user["nick"],
|
||||
"message": message["message"],
|
||||
"created_at": message["created_at"],
|
||||
"html": message["html"],
|
||||
"username": user["username"],
|
||||
}
|
||||
|
||||
async def save(self, model):
|
||||
context = {}
|
||||
context.update(model.record)
|
||||
user = await self.app.services.user.get(model['user_uid'])
|
||||
context.update(
|
||||
{
|
||||
"user_uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"user_nick": user["nick"],
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
return await super().save(model)
|
||||
|
||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||
results = []
|
||||
offset = page * page_size
|
||||
try:
|
||||
if timestamp:
|
||||
async for model in self.query(
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
"offset": offset,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
):
|
||||
results.append(model)
|
||||
elif page > 0:
|
||||
async for model in self.query(
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
"offset": offset,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
):
|
||||
results.append(model)
|
||||
else:
|
||||
async for model in self.query(
|
||||
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"page_size": page_size,
|
||||
"offset": offset,
|
||||
},
|
||||
):
|
||||
results.append(model)
|
||||
|
||||
except:
|
||||
pass
|
||||
results.sort(key=lambda x: x["created_at"])
|
||||
return results
|
||||
@@ -0,0 +1,66 @@
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class ChatService(BaseService):
|
||||
|
||||
async def finalize(self, message_uid):
|
||||
channel_message = await self.services.channel_message.get(uid=message_uid)
|
||||
channel_message["is_final"] = True
|
||||
await self.services.channel_message.save(channel_message)
|
||||
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
|
||||
channel["last_message_on"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
await self.services.socket.broadcast(
|
||||
channel['uid'],
|
||||
{
|
||||
"message": channel_message["message"],
|
||||
"html": channel_message["html"],
|
||||
"user_uid": user['uid'],
|
||||
"color": user["color"],
|
||||
"channel_uid": channel['uid'],
|
||||
"created_at": channel_message["created_at"],
|
||||
"updated_at": channel_message["updated_at"],
|
||||
"username": user["username"],
|
||||
"uid": channel_message["uid"],
|
||||
"user_nick": user["nick"],
|
||||
"is_final": channel_message["is_final"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def send(self, user_uid, channel_uid, message, is_final=True):
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise Exception("Channel not found.")
|
||||
channel_message = await self.services.channel_message.create(
|
||||
channel_uid, user_uid, message,is_final
|
||||
)
|
||||
channel_message_uid = channel_message["uid"]
|
||||
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
channel["last_message_on"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
|
||||
await self.services.socket.broadcast(
|
||||
channel_uid,
|
||||
{
|
||||
"message": channel_message["message"],
|
||||
"html": channel_message["html"],
|
||||
"user_uid": user_uid,
|
||||
"color": user["color"],
|
||||
"channel_uid": channel_uid,
|
||||
"created_at": channel_message["created_at"],
|
||||
"updated_at": None,
|
||||
"username": user["username"],
|
||||
"uid": channel_message["uid"],
|
||||
"user_nick": user["nick"],
|
||||
"is_final": is_final,
|
||||
},
|
||||
)
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(channel_message_uid)
|
||||
)
|
||||
|
||||
return channel_message
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,153 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class DriveService(BaseService):
|
||||
|
||||
mapper_name = "drive"
|
||||
|
||||
EXTENSIONS_PICTURES = ["jpg", "jpeg", "png", "gif", "svg", "webp", "tiff"]
|
||||
EXTENSIONS_VIDEOS = [
|
||||
"mp4",
|
||||
"m4v",
|
||||
"mov",
|
||||
"wmv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"mpg",
|
||||
"mpeg",
|
||||
"avi",
|
||||
"ogv",
|
||||
"ogg",
|
||||
"flv",
|
||||
"3gp",
|
||||
"3g2",
|
||||
]
|
||||
EXTENSIONS_ARCHIVES = [
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"tar.gz",
|
||||
"tar.xz",
|
||||
"tar.bz2",
|
||||
"tar.lzma",
|
||||
"tar.lz",
|
||||
]
|
||||
EXTENSIONS_AUDIO = [
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
"aac",
|
||||
"opus",
|
||||
"aiff",
|
||||
"au",
|
||||
"mid",
|
||||
"midi",
|
||||
]
|
||||
EXTENSIONS_DOCS = [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"txt",
|
||||
"md",
|
||||
"json",
|
||||
"csv",
|
||||
"xml",
|
||||
"html",
|
||||
"css",
|
||||
"js",
|
||||
"py",
|
||||
"sql",
|
||||
"rs",
|
||||
"toml",
|
||||
"yml",
|
||||
"yaml",
|
||||
"ini",
|
||||
"conf",
|
||||
"config",
|
||||
"log",
|
||||
"csv",
|
||||
"tsv",
|
||||
"java",
|
||||
"cs",
|
||||
"csproj",
|
||||
"scss",
|
||||
"less",
|
||||
"sass",
|
||||
"json",
|
||||
"lock",
|
||||
"lock.json",
|
||||
"jsonl",
|
||||
]
|
||||
|
||||
async def get_drive_name_by_extension(self, extension):
|
||||
if extension.startswith("."):
|
||||
extension = extension[1:]
|
||||
if extension in self.EXTENSIONS_PICTURES:
|
||||
return "Pictures"
|
||||
if extension in self.EXTENSIONS_VIDEOS:
|
||||
return "Videos"
|
||||
if extension in self.EXTENSIONS_ARCHIVES:
|
||||
return "Archives"
|
||||
if extension in self.EXTENSIONS_AUDIO:
|
||||
return "Audio"
|
||||
if extension in self.EXTENSIONS_DOCS:
|
||||
return "Documents"
|
||||
return "My Drive"
|
||||
|
||||
async def get_drive_by_extension(self, user_uid, extension):
|
||||
name = await self.get_drive_name_by_extension(extension)
|
||||
return await self.get_or_create(user_uid=user_uid, name=name)
|
||||
|
||||
async def get_by_user(self, user_uid, name=None):
|
||||
kwargs = {"user_uid": user_uid}
|
||||
async for model in self.find(**kwargs):
|
||||
if not name:
|
||||
yield model
|
||||
elif model["name"] == name:
|
||||
yield model
|
||||
elif not model["name"] and name == "My Drive":
|
||||
model["name"] = "My Drive"
|
||||
await self.save(model)
|
||||
yield model
|
||||
|
||||
async def get_or_create(self, user_uid, name=None, extensions=None):
|
||||
kwargs = {"user_uid": user_uid}
|
||||
if name:
|
||||
kwargs["name"] = name
|
||||
async for model in self.get_by_user(**kwargs):
|
||||
return model
|
||||
|
||||
model = await self.new()
|
||||
model["user_uid"] = user_uid
|
||||
model["name"] = name
|
||||
await self.save(model)
|
||||
return model
|
||||
|
||||
async def prepare_default_drives(self):
|
||||
async for drive_item in self.services.drive_item.find():
|
||||
extension = drive_item.extension
|
||||
drive = await self.get_drive_by_extension(drive_item["user_uid"], extension)
|
||||
if not drive_item["drive_uid"] == drive["uid"]:
|
||||
drive_item["drive_uid"] = drive["uid"]
|
||||
await self.services.drive_item.save(drive_item)
|
||||
|
||||
async def prepare_default_drives_for_user(self, user_uid):
|
||||
await self.get_or_create(user_uid=user_uid, name="My Drive")
|
||||
await self.get_or_create(user_uid=user_uid, name="Shared Drive")
|
||||
await self.get_or_create(user_uid=user_uid, name="Pictures")
|
||||
await self.get_or_create(user_uid=user_uid, name="Videos")
|
||||
await self.get_or_create(user_uid=user_uid, name="Archives")
|
||||
await self.get_or_create(user_uid=user_uid, name="Documents")
|
||||
|
||||
async def prepare_all(self):
|
||||
await self.prepare_default_drives()
|
||||
async for user in self.services.user.find():
|
||||
await self.prepare_default_drives_for_user(user["uid"])
|
||||
@@ -0,0 +1,20 @@
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class DriveItemService(BaseService):
|
||||
|
||||
mapper_name = "drive_item"
|
||||
|
||||
async def create(self, drive_uid, name, path, type_, size):
|
||||
model = await self.new()
|
||||
model["drive_uid"] = drive_uid
|
||||
model["name"] = name
|
||||
model["path"] = str(path)
|
||||
model["extension"] = str(name).split(".")[-1]
|
||||
model["file_type"] = type_
|
||||
model["file_size"] = size
|
||||
if await self.save(model):
|
||||
return model
|
||||
errors = await model.errors
|
||||
print("XXXXXXXXXX")
|
||||
raise Exception(f"Failed to create drive item: {errors}.")
|
||||
@@ -0,0 +1,65 @@
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class NotificationService(BaseService):
|
||||
mapper_name = "notification"
|
||||
|
||||
async def mark_as_read(self, user_uid, channel_message_uid):
|
||||
model = await self.get(user_uid, object_uid=channel_message_uid)
|
||||
if not model:
|
||||
return False
|
||||
model["read_at"] = now()
|
||||
await self.save(model)
|
||||
return True
|
||||
|
||||
async def get_unread_stats(self, user_uid):
|
||||
await self.query(
|
||||
"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type",
|
||||
{"user_uid": user_uid},
|
||||
)
|
||||
|
||||
async def create(self, object_uid, object_type, user_uid, message):
|
||||
model = await self.new()
|
||||
model["object_uid"] = object_uid
|
||||
model["object_type"] = object_type
|
||||
model["user_uid"] = user_uid
|
||||
model["message"] = message
|
||||
if await self.save(model):
|
||||
return model
|
||||
raise Exception(f"Failed to create notification: {model.errors}.")
|
||||
|
||||
async def create_channel_message(self, channel_message_uid):
|
||||
channel_message = await self.services.channel_message.get(
|
||||
uid=channel_message_uid
|
||||
)
|
||||
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||
self.app.db.begin()
|
||||
async for channel_member in self.services.channel_member.find(
|
||||
channel_uid=channel_message["channel_uid"],
|
||||
is_banned=False,
|
||||
is_muted=False,
|
||||
deleted_at=None,
|
||||
):
|
||||
if not channel_member["new_count"]:
|
||||
channel_member["new_count"] = 0
|
||||
channel_member["new_count"] += 1
|
||||
|
||||
usr = await self.services.user.get(uid=channel_member["user_uid"])
|
||||
if not usr:
|
||||
continue
|
||||
await self.services.channel_member.save(channel_member)
|
||||
|
||||
model = await self.new()
|
||||
model["object_uid"] = channel_message_uid
|
||||
model["object_type"] = "channel_message"
|
||||
model["user_uid"] = channel_member["user_uid"]
|
||||
model["message"] = (
|
||||
f"New message from {user['nick']} in {channel_member['label']}."
|
||||
)
|
||||
try:
|
||||
await self.save(model)
|
||||
except Exception:
|
||||
raise Exception(f"Failed to create notification: {model.errors}.")
|
||||
|
||||
self.app.db.commit()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,105 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.service import BaseService
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from snek.system.model import now
|
||||
|
||||
class SocketService(BaseService):
|
||||
|
||||
class Socket:
|
||||
def __init__(self, ws, user: UserModel):
|
||||
self.ws = ws
|
||||
self.is_connected = True
|
||||
self.user = user
|
||||
|
||||
|
||||
async def send_json(self, data):
|
||||
if not self.is_connected:
|
||||
return False
|
||||
try:
|
||||
await self.ws.send_json(data)
|
||||
except Exception:
|
||||
self.is_connected = False
|
||||
return self.is_connected
|
||||
|
||||
async def close(self):
|
||||
if not self.is_connected:
|
||||
return True
|
||||
|
||||
await self.ws.close()
|
||||
self.is_connected = False
|
||||
|
||||
return True
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.sockets = set()
|
||||
self.users = {}
|
||||
self.subscriptions = {}
|
||||
self.last_update = str(datetime.now())
|
||||
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
while True:
|
||||
logger.info("Updating user availability...")
|
||||
users_updated = []
|
||||
for s in self.sockets:
|
||||
if not s.is_connected:
|
||||
continue
|
||||
if not s.user in users_updated:
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
users_updated.append(s.user)
|
||||
logger.info(f"Updated user availability for {len(users_updated)} online users.")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def add(self, ws, user_uid):
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.sockets.add(s)
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.info(f"Added socket for user {s.user['username']}")
|
||||
if not self.users.get(user_uid):
|
||||
self.users[user_uid] = set()
|
||||
self.users[user_uid].add(s)
|
||||
|
||||
async def subscribe(self, ws, channel_uid, user_uid):
|
||||
if channel_uid not in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.subscriptions[channel_uid].add(s)
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
count = 0
|
||||
for s in self.users.get(user_uid, []):
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
await self._broadcast(channel_uid, message)
|
||||
|
||||
async def _broadcast(self, channel_uid, message):
|
||||
sent = 0
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(
|
||||
channel_uid
|
||||
):
|
||||
sent += await self.send_to_user(user_uid, message)
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
logger.info(f"Broadcasted a message to {sent} users.")
|
||||
return True
|
||||
|
||||
async def delete(self, ws):
|
||||
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
||||
await s.close()
|
||||
logger.info(f"Removed socket for user {s.user['username']}")
|
||||
self.sockets.remove(s)
|
||||
|
||||
+102
-7
@@ -1,17 +1,112 @@
|
||||
from snek.system.service import BaseService
|
||||
from snek.system import security
|
||||
import pathlib
|
||||
|
||||
from snek.system import security
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
mapper_name = "user"
|
||||
|
||||
async def get_by_username(self, username):
|
||||
return await self.get(username=username)
|
||||
|
||||
async def search(self, query, **kwargs):
|
||||
query = query.strip().lower()
|
||||
if not query:
|
||||
return []
|
||||
results = []
|
||||
async for result in self.find(username={"ilike": "%" + query + "%"}, **kwargs):
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def validate_login(self, username, password):
|
||||
model = await self.get(username=username)
|
||||
if not model:
|
||||
return False
|
||||
if not await security.verify(password, model["password"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def save(self, user):
|
||||
if not user["color"]:
|
||||
user["color"] = await self.services.util.random_light_hex_color()
|
||||
return await super().save(user)
|
||||
|
||||
def authenticate_sync(self,username,password):
|
||||
user = self.get_by_username_sync(username)
|
||||
|
||||
if not user:
|
||||
return False
|
||||
if not security.verify_sync(password, user["password"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def authenticate(self, username, password):
|
||||
success = await self.validate_login(username, password)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
model = await self.get(username=username, deleted_at=None)
|
||||
return model
|
||||
|
||||
def get_admin_uids(self):
|
||||
return self.mapper.get_admin_uids()
|
||||
|
||||
async def get_repository_path(self, user_uid):
|
||||
return pathlib.Path(f"./drive/repositories/{user_uid}")
|
||||
|
||||
async def get_static_path(self, user_uid):
|
||||
path = pathlib.Path(f"./drive/{user_uid}/snek/static")
|
||||
if not path.exists():
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
|
||||
async def get_template_path(self, user_uid):
|
||||
path = pathlib.Path(f"./drive/{user_uid}/snek/templates")
|
||||
if not path.exists():
|
||||
return None
|
||||
return path
|
||||
|
||||
def get_by_username_sync(self, username):
|
||||
user = self.mapper.db["user"].find_one(username=username, deleted_at=None)
|
||||
return dict(user)
|
||||
|
||||
def get_home_folder_by_username(self, username):
|
||||
user = self.get_by_username_sync(username)
|
||||
folder = pathlib.Path(f"./drive/{user['uid']}")
|
||||
if not folder.exists():
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
except:
|
||||
pass
|
||||
return folder
|
||||
|
||||
async def get_home_folder(self, user_uid):
|
||||
folder = pathlib.Path(f"./drive/{user_uid}")
|
||||
if not folder.exists():
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
except:
|
||||
pass
|
||||
return folder
|
||||
|
||||
async def register(self, email, username, password):
|
||||
if await self.exists(username=username):
|
||||
raise Exception("User already exists.")
|
||||
model = await self.new()
|
||||
model.email = email
|
||||
model.username = username
|
||||
model.password = await security.hash(password)
|
||||
model["nick"] = username
|
||||
model["color"] = await self.services.util.random_light_hex_color()
|
||||
model.email.value = email
|
||||
model.username.value = username
|
||||
model.password.value = await security.hash(password)
|
||||
if await self.save(model):
|
||||
return model
|
||||
if model:
|
||||
channel = await self.services.channel.ensure_public_channel(
|
||||
model["uid"]
|
||||
)
|
||||
if not channel:
|
||||
raise Exception("Failed to create public channel.")
|
||||
return model
|
||||
raise Exception(f"Failed to create user: {model.errors}.")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
import random
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class UtilService(BaseService):
|
||||
|
||||
async def random_light_hex_color(self):
|
||||
|
||||
r = random.randint(128, 255)
|
||||
g = random.randint(128, 255)
|
||||
b = random.randint(128, 255)
|
||||
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,76 @@
|
||||
import asyncssh
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
global _app
|
||||
|
||||
def set_app(app):
|
||||
global _app
|
||||
_app = app
|
||||
|
||||
def get_app():
|
||||
return _app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
roots = {}
|
||||
|
||||
class SFTPServer(asyncssh.SFTPServer):
|
||||
|
||||
def __init__(self, chan: asyncssh.SSHServerChannel):
|
||||
self.root = get_app().services.user.get_home_folder_by_username(
|
||||
chan.get_extra_info('username')
|
||||
)
|
||||
self.root.mkdir(exist_ok=True)
|
||||
self.root = str(self.root)
|
||||
super().__init__(chan, chroot=self.root)
|
||||
|
||||
def map_path(self, path):
|
||||
mapped_path = Path(self.root).joinpath(path.lstrip(b"/").decode())
|
||||
logger.debug(f"Mapping client path {path} to {mapped_path}")
|
||||
return str(mapped_path).encode()
|
||||
|
||||
class SSHServer(asyncssh.SSHServer):
|
||||
def password_auth_supported(self):
|
||||
return True
|
||||
|
||||
def validate_password(self, username, password):
|
||||
logger.debug(f"Validating credentials for user {username}")
|
||||
result = get_app().services.user.authenticate_sync(username,password)
|
||||
logger.info(f"Validating credentials for user {username}: {result}")
|
||||
return result
|
||||
|
||||
async def start_ssh_server(app,host,port):
|
||||
set_app(app)
|
||||
logger.info("Starting SFTP server setup")
|
||||
|
||||
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
||||
host_key_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
try:
|
||||
if not host_key_path.exists():
|
||||
logger.info(f"Generating new host key at {host_key_path}")
|
||||
key = asyncssh.generate_private_key("ecdsa-sha2-nistp256")
|
||||
key.write_private_key(host_key_path)
|
||||
else:
|
||||
logger.info(f"Loading existing host key from {host_key_path}")
|
||||
key = asyncssh.read_private_key(host_key_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate or load host key: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Starting SFTP server on 127.0.0.1:{port}")
|
||||
try:
|
||||
x = await asyncssh.listen(
|
||||
host=host,
|
||||
port=port,
|
||||
#process_factory=handle_client,
|
||||
server_host_keys=[key],
|
||||
server_factory=SSHServer,
|
||||
sftp_factory=SFTPServer
|
||||
)
|
||||
return x
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to start SFTP server. Already running.")
|
||||
pass
|
||||
|
||||
|
||||
+259
-81
@@ -1,90 +1,268 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This project implements a client-server communication system using WebSockets and REST APIs.
|
||||
// It features a chat system, a notification sound system, and interaction with server endpoints.
|
||||
|
||||
class Message {
|
||||
uid = null
|
||||
author = null
|
||||
avatar = null
|
||||
text = null
|
||||
time = null
|
||||
constructor(uid,avatar,author,text,time){
|
||||
this.uid = uid
|
||||
this.avatar = avatar
|
||||
this.author = author
|
||||
this.text = text
|
||||
this.time = time
|
||||
// No additional imports were used beyond standard JavaScript objects and constructors.
|
||||
|
||||
// MIT License
|
||||
|
||||
import { Schedule } from "./schedule.js";
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
import { Socket } from "./socket.js";
|
||||
|
||||
export class RESTClient {
|
||||
debug = false;
|
||||
|
||||
async get(url, params = {}) {
|
||||
const encodedParams = new URLSearchParams(params);
|
||||
if (encodedParams) url += "?" + encodedParams;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
if (this.debug) {
|
||||
console.debug({ url, params, result });
|
||||
}
|
||||
|
||||
get links() {
|
||||
if(!this.text)
|
||||
return []
|
||||
let result = []
|
||||
for(let part in this.text.split(/[,; ]/)){
|
||||
if(part.startsWith("http") || part.startsWith("www.") || part.indexOf(".com") || part.indexOf(".net") || part.indexOf(".io") || part.indexOf(".nl")){
|
||||
result.push(part)
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
async post(url, data) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (this.debug) {
|
||||
console.debug({ url, data, result });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class Chat extends EventHandler {
|
||||
constructor() {
|
||||
super();
|
||||
this._url =
|
||||
window.location.hostname === "localhost"
|
||||
? "ws://localhost/chat.ws"
|
||||
: "wss://" + window.location.hostname + "/chat.ws";
|
||||
this._socket = null;
|
||||
this._waitConnect = null;
|
||||
this._promises = {};
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this._waitConnect) {
|
||||
return this._waitConnect;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this._waitConnect = resolve;
|
||||
console.debug("Connecting..");
|
||||
|
||||
try {
|
||||
this._socket = new WebSocket(this._url);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setTimeout(() => {
|
||||
this.ensureConnection();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this._socket.onconnect = () => {
|
||||
this._connected();
|
||||
this._waitSocket();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
generateUniqueId() {
|
||||
return "id-" + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
call(method, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const command = { method, args, message_id: this.generateUniqueId() };
|
||||
this._promises[command.message_id] = resolve;
|
||||
this._socket.send(JSON.stringify(command));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_connected() {
|
||||
this._socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.message_id && this._promises[message.message_id]) {
|
||||
this._promises[message.message_id](message);
|
||||
delete this._promises[message.message_id];
|
||||
} else {
|
||||
this.emit("message", message);
|
||||
}
|
||||
};
|
||||
this._socket.onclose = () => {
|
||||
this._waitSocket = null;
|
||||
this._socket = null;
|
||||
this.emit("close");
|
||||
};
|
||||
}
|
||||
|
||||
async privmsg(room, text) {
|
||||
await rest.post("/api/privmsg", {
|
||||
room,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationAudio {
|
||||
constructor(timeout = 500) {
|
||||
this.schedule = new Schedule(timeout);
|
||||
}
|
||||
|
||||
sounds = {
|
||||
message: "/audio/soundfx.d_beep3.mp3",
|
||||
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
|
||||
messageOtherChannel:
|
||||
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
|
||||
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
|
||||
};
|
||||
|
||||
play(soundIndex = 0) {
|
||||
this.schedule.delay(() => {
|
||||
new Audio(this.sounds[soundIndex])
|
||||
.play()
|
||||
.then(() => {
|
||||
console.debug("Gave sound notification");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Notification failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class App extends EventHandler {
|
||||
rest = new RESTClient();
|
||||
ws = null;
|
||||
rpc = null;
|
||||
audio = null;
|
||||
user = {};
|
||||
typeLock = null;
|
||||
typeListener = null;
|
||||
typeEventChannelUid = null;
|
||||
_debug = false
|
||||
async set_typing(channel_uid) {
|
||||
this.typeEventChannel_uid = channel_uid;
|
||||
}
|
||||
debug() {
|
||||
this._debug = !this._debug;
|
||||
this.ws._debug = this._debug;
|
||||
}
|
||||
async ping(...args) {
|
||||
if (this.is_pinging) return false;
|
||||
this.is_pinging = true;
|
||||
await this.rpc.ping(...args);
|
||||
this.is_pinging = false;
|
||||
}
|
||||
|
||||
async forcePing(...arg) {
|
||||
await this.rpc.ping(...args);
|
||||
}
|
||||
starField = null
|
||||
constructor() {
|
||||
super();
|
||||
this.ws = new Socket();
|
||||
this.rpc = this.ws.client;
|
||||
this.audio = new NotificationAudio(500);
|
||||
this.is_pinging = false;
|
||||
this.ping_interval = setInterval(() => {
|
||||
this.ping("active");
|
||||
}, 15000);
|
||||
this.typeEventChannelUid = null;
|
||||
this.typeListener = setInterval(() => {
|
||||
if (this.typeEventChannelUid) {
|
||||
this.rpc.set_typing(this.typeEventChannelUid);
|
||||
this.typeEventChannelUid = null;
|
||||
}
|
||||
});
|
||||
|
||||
const me = this;
|
||||
this.ws.addEventListener("connected", (data) => {
|
||||
this.ping("online");
|
||||
});
|
||||
this.ws.addEventListener("reconnecting", (data) => {
|
||||
this.starField?.showNotify("Connecting..","#CC0000")
|
||||
})
|
||||
this.ws.addEventListener("channel-message", (data) => {
|
||||
me.emit("channel-message", data);
|
||||
});
|
||||
this.ws.addEventListener("data", (data) => {
|
||||
if(this._debug){
|
||||
console.debug(data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
get mentions() {
|
||||
if(!this.text)
|
||||
return []
|
||||
let result = []
|
||||
for(let part in this.text.split(/[,; ]/)){
|
||||
if(part.startsWith("@")){
|
||||
result.push(part)
|
||||
});
|
||||
this.rpc.getUser(null).then((user) => {
|
||||
me.user = user;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return result
|
||||
playSound(index) {
|
||||
this.audio.play(index);
|
||||
}
|
||||
|
||||
timeDescription(isoDate) {
|
||||
const date = new Date(isoDate);
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
timeAgo(date1, date2) {
|
||||
const diffMs = Math.abs(date2 - date1);
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(
|
||||
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
|
||||
);
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
if (days) {
|
||||
return `${days} ${days > 1 ? "days" : "day"} ago`;
|
||||
}
|
||||
if (hours) {
|
||||
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
|
||||
}
|
||||
if (minutes) {
|
||||
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
async benchMark(times = 100, message = "Benchmark Message") {
|
||||
const promises = [];
|
||||
const me = this;
|
||||
for (let i = 0; i < times; i++) {
|
||||
promises.push(
|
||||
this.rpc.getChannels().then((channels) => {
|
||||
channels.forEach((channel) => {
|
||||
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Messages {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
class Room {
|
||||
name = null
|
||||
messages = []
|
||||
constructor(name){
|
||||
this.name = name
|
||||
}
|
||||
setMessages(list){
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
class InlineAppElement extends HTMLElement {
|
||||
|
||||
constructor(){
|
||||
this.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Page {
|
||||
elements = []
|
||||
|
||||
}
|
||||
|
||||
class App {
|
||||
rooms = []
|
||||
constructor() {
|
||||
this.rooms.push(new Room("General"))
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
export const app = new App();
|
||||
window.app = app;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
.back-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
fancy-button {
|
||||
grid-column: 1 / 1;
|
||||
grid-row: 1 / 1;
|
||||
z-index: 1;
|
||||
margin-left: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
generic-form {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
}
|
||||
+499
-111
@@ -1,26 +1,53 @@
|
||||
@import "shared.css";
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
padding: 50px;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gallery.tile, .tile {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
margin: 20px 10px 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
background-color: #000000;
|
||||
color: #e6e6e6;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"sidebar chat-area";
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
grid-area: chat-area;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #0f0f0f;
|
||||
grid-area: header;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -29,8 +56,9 @@ header {
|
||||
|
||||
header .logo {
|
||||
color: #fff;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
@@ -41,22 +69,294 @@ header nav a {
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
header nav a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
a {
|
||||
font-weight: bold;
|
||||
color: #f05a28;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4em;
|
||||
color: #f05a28;
|
||||
}
|
||||
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
padding-bottom: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-messages, .threads {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 20px;
|
||||
color: #f05a28;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.chat-messages picture img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-messages .message, .threads .thread {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chat-messages .message .avatar, .threads .thread .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content, .threads .thread .message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .author, .threads .thread .message-content .author {
|
||||
font-weight: bold;
|
||||
color: #f05a28;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
* {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.highlight pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .text, .threads .thread .message-content .text {
|
||||
margin-bottom: 5px;
|
||||
color: #e6e6e6;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
|
||||
img, video, iframe, div {
|
||||
max-width: 90%;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .time, .threads .thread .message-content .time {
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input[type="text"], .chat-input textarea {
|
||||
flex: 1;
|
||||
color: white;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input upload-button {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
.text {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:not(:hover, :focus-within, :active) .time {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message.switch-user {
|
||||
.text img, iframe, video {
|
||||
max-width: 90%;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
user-select: none;
|
||||
opacity: 1;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
|
||||
.time {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* The entire scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.chat-messages, .threads {
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: #121212;
|
||||
padding: 20px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 10px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #333;
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
@@ -84,116 +384,204 @@ main {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #1a1a1a;
|
||||
@keyframes glow {
|
||||
0% {
|
||||
box-shadow: 0 0 5px #3498db;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 5px #3498db;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px 20px;
|
||||
background-color: #0f0f0f;
|
||||
border-bottom: 1px solid #333;
|
||||
.glow {
|
||||
animation: glow 1s;
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
|
||||
header {
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
body {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
header{
|
||||
position: sticky;
|
||||
display: block;
|
||||
.logo {
|
||||
display:block;
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
position:sticky;
|
||||
}*/
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.chat-messages .message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: #222;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.chat-messages .message .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .author {
|
||||
font-weight: bold;
|
||||
color: #f05a28;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .text {
|
||||
margin-bottom: 5px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.chat-messages .message .message-content .time {
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 15px;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
resize: none;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
background-color: #000; /* Deep black */
|
||||
color: #f1f1f1;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
|
||||
animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
/* Backdrop styling */
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Title and content */
|
||||
dialog .dialog-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
dialog .dialog-content {
|
||||
font-size: 1rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Button layout */
|
||||
dialog .dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
dialog .dialog-button {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@keyframes dialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialogScaleIn {
|
||||
from {
|
||||
transform: scale(0.95) translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary {
|
||||
background-color: #f05a28;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
dialog .dialog-button.primary:hover {
|
||||
background-color: #f05a28;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary {
|
||||
background-color: #f0a328;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
dialog .dialog-button.secondary:hover {
|
||||
background-color: #f0b84c;
|
||||
}
|
||||
|
||||
|
||||
.embed-url-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.embed-url-link img,
|
||||
.embed-url-link video,
|
||||
.embed-url-link iframe,
|
||||
.embed-url-link div {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.embed-url-link .page-site {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.embed-url-link .page-name {
|
||||
font-size: 1.2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.embed-url-link .page-description {
|
||||
font-size: 1em;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
}
|
||||
color: #e6e6e6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.embed-url-link .page-link {
|
||||
font-size: 0.9em;
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { app } from "../app.js";
|
||||
|
||||
class ChatInputComponent extends HTMLElement {
|
||||
autoCompletions = {
|
||||
"example 1": () => {},
|
||||
"example 2": () => {},
|
||||
}
|
||||
hiddenCompletions = {
|
||||
"/starsRender": () => {
|
||||
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
||||
}
|
||||
}
|
||||
users = []
|
||||
textarea = null
|
||||
_value = ""
|
||||
lastUpdateEvent = null
|
||||
previousValue = ""
|
||||
lastChange = null
|
||||
changed = false
|
||||
constructor() {
|
||||
super();
|
||||
this.lastUpdateEvent = new Date();
|
||||
this.textarea = document.createElement("textarea");
|
||||
this.value = this.getAttribute("value") || "";
|
||||
this.previousValue = this.value;
|
||||
this.lastChange = new Date();
|
||||
this.changed = false;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._value = value || "";
|
||||
this.textarea.value = this._value;
|
||||
}
|
||||
get allAutoCompletions() {
|
||||
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
|
||||
}
|
||||
resolveAutoComplete() {
|
||||
let count = 0;
|
||||
let value = null;
|
||||
|
||||
Object.keys(this.allAutoCompletions).forEach((key) => {
|
||||
if (key.startsWith(this.value.split(" ")[0])) {
|
||||
count++;
|
||||
value = key;
|
||||
}
|
||||
});
|
||||
if (count == 1) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return document.activeElement === this.textarea;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.textarea.focus();
|
||||
}
|
||||
getAuthors(){
|
||||
let authors = []
|
||||
for (let i = 0; i < this.users.length; i++) {
|
||||
authors.push(this.users[i].username)
|
||||
authors.push(this.users[i].nick)
|
||||
}
|
||||
return authors
|
||||
|
||||
}
|
||||
extractMentions(text) {
|
||||
const regex = /@([a-zA-Z0-9_-]+)/g;
|
||||
const mentions = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
mentions.push(match[1]);
|
||||
}
|
||||
|
||||
return mentions;
|
||||
}
|
||||
matchMentionsToAuthors(mentions, authors) {
|
||||
return mentions.map(mention => {
|
||||
let closestAuthor = null;
|
||||
let minDistance = Infinity;
|
||||
const lowerMention = mention.toLowerCase();
|
||||
|
||||
authors.forEach(author => {
|
||||
const lowerAuthor = author.toLowerCase();
|
||||
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||
|
||||
|
||||
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
|
||||
distance += 10
|
||||
}
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestAuthor = author;
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return { mention, closestAuthor, distance: minDistance };
|
||||
});
|
||||
}
|
||||
levenshteinDistance(a, b) {
|
||||
const matrix = [];
|
||||
|
||||
// Initialize the first row and column
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
// Fill in the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // Deletion
|
||||
matrix[i][j - 1] + 1, // Insertion
|
||||
matrix[i - 1][j - 1] + 1 // Substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
|
||||
|
||||
replaceMentionsWithAuthors(text) {
|
||||
const authors = this.getAuthors();
|
||||
const mentions = this.extractMentions(text);
|
||||
|
||||
const matches = this.matchMentionsToAuthors(mentions, authors);
|
||||
let updatedText = text;
|
||||
matches.forEach(({ mention, closestAuthor }) => {
|
||||
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
||||
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
||||
});
|
||||
|
||||
return updatedText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async connectedCallback() {
|
||||
this.user = null
|
||||
app.rpc.getUser(null).then((user) => {
|
||||
this.user=user
|
||||
})
|
||||
|
||||
|
||||
const me = this;
|
||||
this.liveType = this.getAttribute("live-type") === "true";
|
||||
this.liveTypeInterval =
|
||||
parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||
this.channelUid = this.getAttribute("channel");
|
||||
|
||||
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
||||
this.users = users
|
||||
})
|
||||
this.messageUid = null;
|
||||
|
||||
this.classList.add("chat-input");
|
||||
|
||||
this.textarea.setAttribute("placeholder", "Type a message...");
|
||||
this.textarea.setAttribute("rows", "2");
|
||||
|
||||
this.appendChild(this.textarea);
|
||||
|
||||
this.uploadButton = document.createElement("upload-button");
|
||||
this.uploadButton.setAttribute("channel", this.channelUid);
|
||||
this.uploadButton.addEventListener("upload", (e) => {
|
||||
this.dispatchEvent(new CustomEvent("upload", e));
|
||||
});
|
||||
this.uploadButton.addEventListener("uploaded", (e) => {
|
||||
this.dispatchEvent(new CustomEvent("uploaded", e));
|
||||
});
|
||||
|
||||
this.appendChild(this.uploadButton);
|
||||
|
||||
this.textarea.addEventListener("keyup", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
this.value = "";
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
this.value = e.target.value;
|
||||
this.changed = true;
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.textarea.addEventListener("keydown", (e) => {
|
||||
this.value = e.target.value;
|
||||
let autoCompletion = null;
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
autoCompletion = this.resolveAutoComplete();
|
||||
if (autoCompletion) {
|
||||
e.target.value = autoCompletion;
|
||||
this.value = autoCompletion;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
const message = me.replaceMentionsWithAuthors(this.value);
|
||||
e.target.value = "";
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
|
||||
if (autoCompletionHandler) {
|
||||
autoCompletionHandler();
|
||||
this.value = "";
|
||||
this.previousValue = "";
|
||||
e.target.value = "";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateMessage()
|
||||
app.rpc.finalizeMessage(this.messageUid)
|
||||
this.value = "";
|
||||
this.previousValue = "";
|
||||
this.messageUid = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.changeInterval = setInterval(() => {
|
||||
if (!this.liveType) {
|
||||
return;
|
||||
}
|
||||
if (this.value !== this.previousValue) {
|
||||
if (
|
||||
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
|
||||
this.liveTypeInterval
|
||||
) {
|
||||
this.value = "";
|
||||
this.previousValue = "";
|
||||
}
|
||||
this.lastChange = new Date();
|
||||
}
|
||||
this.update();
|
||||
}, 300);
|
||||
|
||||
this.addEventListener("upload", (e) => {
|
||||
this.focus();
|
||||
});
|
||||
this.addEventListener("uploaded", function (e) {
|
||||
let message = "";
|
||||
e.detail.files.forEach((file) => {
|
||||
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||
});
|
||||
app.rpc.sendMessage(this.channelUid, message,true);
|
||||
});
|
||||
setTimeout(()=>{
|
||||
this.focus();
|
||||
},1000)
|
||||
}
|
||||
|
||||
trackSecondsBetweenEvents(event1Time, event2Time) {
|
||||
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
||||
return millisecondsDifference / 1000;
|
||||
}
|
||||
isSubsequence(s, t) {
|
||||
let i = 0, j = 0;
|
||||
while (i < s.length && j < t.length) {
|
||||
if (s[i] === t[j]) {
|
||||
i++;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
return i === s.length;
|
||||
}
|
||||
|
||||
|
||||
newMessage() {
|
||||
if (!this.messageUid) {
|
||||
this.messageUid = "?";
|
||||
}
|
||||
|
||||
this.value = this.replaceMentionsWithAuthors(this.value);
|
||||
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
|
||||
if (this.liveType) {
|
||||
this.messageUid = uid;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateMessage() {
|
||||
if (this.value[0] == "/") {
|
||||
return false;
|
||||
}
|
||||
if (!this.messageUid) {
|
||||
this.newMessage();
|
||||
return false;
|
||||
}
|
||||
if (this.messageUid === "?") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof app !== "undefined" &&
|
||||
app.rpc &&
|
||||
typeof app.rpc.updateMessageText === "function"
|
||||
) {
|
||||
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus() {
|
||||
if (this.liveType) {
|
||||
return;
|
||||
}
|
||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
|
||||
this.lastUpdateEvent = new Date();
|
||||
if (
|
||||
typeof app !== "undefined" &&
|
||||
app.rpc &&
|
||||
typeof app.rpc.set_typing === "function"
|
||||
) {
|
||||
app.rpc.set_typing(this.channelUid, this.user.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const expired =
|
||||
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
|
||||
this.liveTypeInterval;
|
||||
const changed = this.value !== this.previousValue;
|
||||
|
||||
if (changed || expired) {
|
||||
this.lastChange = new Date();
|
||||
this.updateStatus();
|
||||
}
|
||||
|
||||
this.previousValue = this.value;
|
||||
|
||||
if (this.liveType && expired) {
|
||||
this.value = "";
|
||||
this.previousValue = "";
|
||||
this.messageUid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (this.liveType) {
|
||||
this.updateMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(channelUid, value,is_final) {
|
||||
if (!value.trim()) {
|
||||
return null;
|
||||
}
|
||||
return await app.rpc.sendMessage(channelUid, value,is_final);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-input", ChatInputComponent);
|
||||
@@ -0,0 +1,82 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines a custom HTML element called ChatWindowElement that provides a chat interface within a shadow DOM, handling connection callbacks, displaying messages, and user interactions.
|
||||
|
||||
// No external imports or includes other than standard DOM and HTML elements.
|
||||
|
||||
// The MIT License (MIT)
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
class ChatWindowElement extends HTMLElement {
|
||||
receivedHistory = false;
|
||||
channel = null;
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.component = document.createElement("section");
|
||||
this.app = app;
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
}
|
||||
|
||||
get user() {
|
||||
return this.app.user;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/base.css";
|
||||
this.component.appendChild(link);
|
||||
this.component.classList.add("chat-area");
|
||||
|
||||
this.container = document.createElement("section");
|
||||
this.container.classList.add("chat-area", "chat-window");
|
||||
|
||||
const chatHeader = document.createElement("div");
|
||||
chatHeader.classList.add("chat-header");
|
||||
|
||||
const chatTitle = document.createElement("h2");
|
||||
chatTitle.classList.add("chat-title");
|
||||
chatTitle.classList.add("no-select");
|
||||
chatTitle.innerText = "Loading...";
|
||||
chatHeader.appendChild(chatTitle);
|
||||
this.container.appendChild(chatHeader);
|
||||
|
||||
const channels = await app.rpc.getChannels();
|
||||
const channel = channels[0];
|
||||
this.channel = channel;
|
||||
chatTitle.innerText = channel.name;
|
||||
|
||||
const channelElement = document.createElement("message-list");
|
||||
channelElement.setAttribute("channel", channel.uid);
|
||||
this.container.appendChild(channelElement);
|
||||
|
||||
const chatInput = document.createElement("chat-input");
|
||||
chatInput.chatWindow = this;
|
||||
chatInput.addEventListener("submit", (e) => {
|
||||
app.rpc.sendMessage(channel.uid, e.detail);
|
||||
});
|
||||
this.container.appendChild(chatInput);
|
||||
|
||||
this.component.appendChild(this.container);
|
||||
|
||||
const messages = await app.rpc.getMessages(channel.uid);
|
||||
messages.forEach((message) => {
|
||||
if (!message["user_nick"]) return;
|
||||
channelElement.addMessage(message);
|
||||
});
|
||||
|
||||
const me = this;
|
||||
channelElement.addEventListener("message", (message) => {
|
||||
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
||||
|
||||
message.detail.element.scrollIntoView({ block: "end" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-window", ChatWindowElement);
|
||||
@@ -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">></span>
|
||||
<input type="text" id="input" autocomplete="off" autofocus />
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.outputEl = this.shadowRoot.getElementById("output");
|
||||
this.inputEl = this.shadowRoot.getElementById("input");
|
||||
|
||||
this.history = [];
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
const value = this.inputEl.value;
|
||||
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
this.executeCommand(value);
|
||||
this.history.push(value);
|
||||
this.historyIndex = this.history.length;
|
||||
this.inputEl.value = "";
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
if (this.historyIndex > 0) {
|
||||
this.historyIndex--;
|
||||
this.inputEl.value = this.history[this.historyIndex];
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
if (this.historyIndex < this.history.length - 1) {
|
||||
this.historyIndex++;
|
||||
this.inputEl.value = this.history[this.historyIndex];
|
||||
} else {
|
||||
this.historyIndex = this.history.length;
|
||||
this.inputEl.value = "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand(command) {
|
||||
const outputLine = document.createElement("div");
|
||||
outputLine.textContent = `> ${command}`;
|
||||
this.outputEl.appendChild(outputLine);
|
||||
|
||||
const resultLine = document.createElement("div");
|
||||
resultLine.textContent = this.mockExecute(command);
|
||||
this.outputEl.appendChild(resultLine);
|
||||
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
|
||||
mockExecute(command) {
|
||||
switch (command.trim()) {
|
||||
case "help":
|
||||
return "Available commands: help, clear, date";
|
||||
case "date":
|
||||
return new Date().toString();
|
||||
case "clear":
|
||||
this.outputEl.innerHTML = "";
|
||||
return "";
|
||||
default:
|
||||
return `Unknown command: ${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to create a modal dialog with the terminal
|
||||
* @returns {HTMLDialogElement}
|
||||
*/
|
||||
static createModal() {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-backdrop">
|
||||
<web-terminal></web-terminal>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
return dialog;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("web-terminal", WebTerminal);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 890 KiB |
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
||||
|
||||
// MIT License
|
||||
|
||||
class FancyButton extends HTMLElement {
|
||||
url = null
|
||||
type="button"
|
||||
value = null
|
||||
constructor(){
|
||||
super()
|
||||
this.attachShadow({mode:'open'})
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.url = null;
|
||||
this.type = "button";
|
||||
this.value = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
connectedCallback() {
|
||||
this.container = document.createElement("span");
|
||||
let size = this.getAttribute("size");
|
||||
console.info({ GG: size });
|
||||
size = size === "auto" ? "1%" : "33%";
|
||||
|
||||
this.container = document.createElement('span')
|
||||
let size = this.getAttribute('size')
|
||||
console.info({GG:size})
|
||||
if(size == 'auto'){
|
||||
size = '1%'
|
||||
}else{
|
||||
size = '33%'
|
||||
}
|
||||
this.styleElement = document.createElement("style")
|
||||
this.styleElement.innerHTML = `
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.innerHTML = `
|
||||
:root {
|
||||
width:100%;
|
||||
width: 100%;
|
||||
--width: 100%;
|
||||
}
|
||||
}
|
||||
button {
|
||||
width: var(--width);
|
||||
min-width: ${size};
|
||||
@@ -37,32 +37,39 @@ class FancyButton extends HTMLElement {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
border: 1px solid #f05a28;
|
||||
}
|
||||
border: 1px solid #f05a28;
|
||||
}
|
||||
button:hover {
|
||||
color: #EFEFEF;
|
||||
background-color: #e04924;
|
||||
border: 1px solid #efefef;
|
||||
color: #EFEFEF;
|
||||
background-color: #e04924;
|
||||
border: 1px solid #efefef;
|
||||
}
|
||||
`;
|
||||
|
||||
this.container.appendChild(this.styleElement);
|
||||
this.buttonElement = document.createElement("button");
|
||||
this.container.appendChild(this.buttonElement);
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
|
||||
this.url = this.getAttribute("url");
|
||||
|
||||
this.value = this.getAttribute("value");
|
||||
this.buttonElement.appendChild(
|
||||
document.createTextNode(this.getAttribute("text")),
|
||||
);
|
||||
this.buttonElement.addEventListener("click", () => {
|
||||
if (this.url == "submit") {
|
||||
this.closest("form").submit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.url === "/back" || this.url === "/back/") {
|
||||
window.history.back();
|
||||
} else if (this.url) {
|
||||
window.location = this.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
`
|
||||
this.container.appendChild(this.styleElement)
|
||||
this.buttonElement = document.createElement('button')
|
||||
this.container.appendChild(this.buttonElement)
|
||||
this.shadowRoot.appendChild(this.container)
|
||||
|
||||
this.url = this.getAttribute('url');
|
||||
this.value = this.getAttribute('value')
|
||||
const me = this
|
||||
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")))
|
||||
this.buttonElement.addEventListener("click",()=>{
|
||||
if(me.url == "/back" || me.url == "/back/"){
|
||||
window.history.back()
|
||||
}else if(me.url){
|
||||
window.location = me.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("fancy-button",FancyButton)
|
||||
customElements.define("fancy-button", FancyButton);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -14,6 +14,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
generic-form {
|
||||
@@ -29,16 +30,15 @@ generic-form {
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 400px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
.generic-form-container h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
|
||||
}
|
||||
input {
|
||||
|
||||
@@ -93,8 +93,7 @@ input {
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: 600px) {
|
||||
.generic-form-container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+411
-310
@@ -1,347 +1,448 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines two custom HTML elements, `GenericField` and `GenericForm`. The `GenericField` element represents a form field with validation and styling functionalities, and the `GenericForm` fetches and manages form data, handling field validation and submission.
|
||||
|
||||
// No external imports are present; all utilized functionality is native to JavaScript and web APIs.
|
||||
|
||||
// MIT License
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
class GenericField extends HTMLElement {
|
||||
form = null
|
||||
field = null
|
||||
inputElement = null
|
||||
footerElement = null
|
||||
action = null
|
||||
container = null
|
||||
styleElement = null
|
||||
name = null
|
||||
get value() {
|
||||
return this.inputElement.value
|
||||
}
|
||||
get type() {
|
||||
form = null;
|
||||
field = null;
|
||||
inputElement = null;
|
||||
footerElement = null;
|
||||
action = null;
|
||||
container = null;
|
||||
styleElement = null;
|
||||
name = null;
|
||||
|
||||
return this.field.tag
|
||||
}
|
||||
set value(val) {
|
||||
val = val == null ? '' : val
|
||||
this.inputElement.value = val
|
||||
this.inputElement.setAttribute("value", val)
|
||||
}
|
||||
setInvalid(){
|
||||
this.inputElement.classList.add("error")
|
||||
this.inputElement.classList.remove("valid")
|
||||
}
|
||||
setErrors(errors){
|
||||
if(errors.length)
|
||||
this.inputElement.setAttribute("title", errors[0])
|
||||
else
|
||||
this.inputElement.setAttribute("title","")
|
||||
}
|
||||
setValid(){
|
||||
this.inputElement.classList.remove("error")
|
||||
this.inputElement.classList.add("valid")
|
||||
}
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({mode:'open'})
|
||||
this.container = document.createElement('div')
|
||||
this.styleElement = document.createElement('style')
|
||||
this.styleElement.innerHTML = `
|
||||
get value() {
|
||||
return this.inputElement.value;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
get type() {
|
||||
return this.field.tag;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
}
|
||||
set value(val) {
|
||||
val = val ?? "";
|
||||
this.inputElement.value = val;
|
||||
this.inputElement.setAttribute("value", val);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 50%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
clear: both;
|
||||
}
|
||||
setInvalid() {
|
||||
this.inputElement.classList.add("error");
|
||||
this.inputElement.classList.remove("valid");
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
setErrors(errors) {
|
||||
const errorText = errors.length ? errors[0] : "";
|
||||
this.inputElement.setAttribute("title", errorText);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
setValid() {
|
||||
this.inputElement.classList.remove("error");
|
||||
this.inputElement.classList.add("valid");
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #e04924;
|
||||
}
|
||||
.valid {
|
||||
border: 1px solid green;
|
||||
color:green;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.error {
|
||||
border: 3px solid red;
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
input {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
`
|
||||
this.container.appendChild(this.styleElement)
|
||||
|
||||
this.shadowRoot.appendChild(this.container)
|
||||
}
|
||||
connectedCallback(){
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.container = document.createElement("div");
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.innerHTML = `
|
||||
|
||||
this.updateAttributes()
|
||||
|
||||
}
|
||||
setAttribute(name,value){
|
||||
this[name] = value
|
||||
}
|
||||
updateAttributes(){
|
||||
if(this.inputElement == null && this.field){
|
||||
this.inputElement = document.createElement(this.field.tag)
|
||||
if(this.field.tag == 'button'){
|
||||
if(this.field.value == "submit"){
|
||||
|
||||
|
||||
}
|
||||
this.action = this.field.value
|
||||
}
|
||||
this.inputElement.name = this.field.name
|
||||
this.name = this.inputElement.name
|
||||
const me = this
|
||||
this.inputElement.addEventListener("keyup",(e)=>{
|
||||
if(e.key == 'Enter'){
|
||||
me.dispatchEvent(new Event("submit"))
|
||||
}else if(me.field.value != e.target.value)
|
||||
{
|
||||
const event = new CustomEvent("change", {detail:me,bubbles:true})
|
||||
me.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
this.inputElement.addEventListener("click",(e)=>{
|
||||
const event = new CustomEvent("click",{detail:me,bubbles:true})
|
||||
me.dispatchEvent(event)
|
||||
})
|
||||
this.container.appendChild(this.inputElement)
|
||||
|
||||
}
|
||||
if(!this.field){
|
||||
return
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
color: #f05a28;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
this.inputElement.setAttribute("type",this.field.type == null ? 'input' : this.field.type)
|
||||
this.inputElement.setAttribute("name",this.field.name == null ? '' : this.field.name)
|
||||
|
||||
input {
|
||||
width: 90%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
font-size: 1em;
|
||||
|
||||
if(this.field.text != null){
|
||||
this.inputElement.innerText = this.field.text
|
||||
&:focus {
|
||||
outline: 2px solid #f05a28 !important;
|
||||
}
|
||||
if(this.field.html != null){
|
||||
this.inputElement.innerHTML = this.field.html
|
||||
|
||||
&::placeholder {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
if(this.field.class_name){
|
||||
this.inputElement.classList.add(this.field.class_name)
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
this.inputElement.setAttribute("tabindex", this.field.index)
|
||||
this.inputElement.classList.add(this.field.name)
|
||||
this.value = this.field.value
|
||||
let place_holder = null
|
||||
if(this.field.place_holder)
|
||||
place_holder = this.field.place_holder
|
||||
if(this.field.required && place_holder){
|
||||
place_holder = place_holder
|
||||
}
|
||||
|
||||
button {
|
||||
width: 50%;
|
||||
padding: 10px;
|
||||
background-color: #f05a28;
|
||||
border: none;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #e04924;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #f05a28;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #e04924;
|
||||
}
|
||||
|
||||
.valid {
|
||||
border: 1px solid green;
|
||||
color: green;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 3px solid red;
|
||||
color: #d8000c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
input {
|
||||
width: 90%;
|
||||
}
|
||||
if(place_holder)
|
||||
this.field.place_holder = "* " + place_holder
|
||||
this.inputElement.setAttribute("placeholder",place_holder)
|
||||
if(this.field.required)
|
||||
this.inputElement.setAttribute("required","required")
|
||||
else
|
||||
this.inputElement.removeAttribute("required")
|
||||
if(!this.footerElement){
|
||||
this.footerElement = document.createElement('div')
|
||||
this.footerElement.style.clear = 'both'
|
||||
this.container.appendChild(this.footerElement)
|
||||
}
|
||||
`;
|
||||
this.container.appendChild(this.styleElement);
|
||||
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.updateAttributes();
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
|
||||
focus(options) {
|
||||
this.inputElement?.focus(options);
|
||||
}
|
||||
|
||||
updateAttributes() {
|
||||
const inputUpdate = this.inputElement != null;
|
||||
|
||||
if (this.inputElement == null && this.field) {
|
||||
this.inputElement = document.createElement(this.field.tag);
|
||||
if (this.field.tag === "button" && this.field.value === "submit") {
|
||||
this.action = this.field.value;
|
||||
}
|
||||
this.inputElement.name = this.field.name;
|
||||
this.name = this.inputElement.name;
|
||||
|
||||
const me = this;
|
||||
this.inputElement.addEventListener("keyup", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const event = new CustomEvent("change", {
|
||||
detail: me,
|
||||
bubbles: true,
|
||||
});
|
||||
me.dispatchEvent(event);
|
||||
|
||||
me.dispatchEvent(new Event("submit"));
|
||||
} else if (me.field.value !== e.target.value) {
|
||||
const event = new CustomEvent("change", {
|
||||
detail: me,
|
||||
bubbles: true,
|
||||
});
|
||||
me.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.inputElement.addEventListener("click", (e) => {
|
||||
const event = new CustomEvent("click", { detail: me, bubbles: true });
|
||||
me.dispatchEvent(event);
|
||||
});
|
||||
|
||||
this.inputElement.addEventListener(
|
||||
"blur",
|
||||
(e) => {
|
||||
const event = new CustomEvent("change", {
|
||||
detail: me,
|
||||
bubbles: true,
|
||||
});
|
||||
me.dispatchEvent(event);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
this.container.appendChild(this.inputElement);
|
||||
}
|
||||
|
||||
if (!this.field) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputElement.setAttribute("type", this.field.type ?? "input");
|
||||
this.inputElement.setAttribute("name", this.field.name ?? "");
|
||||
|
||||
if (this.field.text != null) {
|
||||
this.inputElement.innerText = this.field.text;
|
||||
}
|
||||
if (this.field.html != null) {
|
||||
this.inputElement.innerHTML = this.field.html;
|
||||
}
|
||||
if (this.field.class_name) {
|
||||
this.inputElement.classList.add(this.field.class_name);
|
||||
}
|
||||
this.inputElement.setAttribute("tabindex", this.field.index);
|
||||
this.inputElement.classList.add(this.field.name);
|
||||
if (this.field.value != null || !inputUpdate) {
|
||||
this.value = this.field.value;
|
||||
}
|
||||
|
||||
let place_holder = this.field.place_holder ?? null;
|
||||
if (this.field.required && place_holder) {
|
||||
place_holder = "* " + place_holder;
|
||||
}
|
||||
this.inputElement.setAttribute("placeholder", place_holder);
|
||||
if (this.field.required) {
|
||||
this.inputElement.setAttribute("required", "required");
|
||||
} else {
|
||||
this.inputElement.removeAttribute("required");
|
||||
}
|
||||
if (!this.footerElement) {
|
||||
this.footerElement = document.createElement("div");
|
||||
this.footerElement.style.clear = "both";
|
||||
this.container.appendChild(this.footerElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('generic-field', GenericField);
|
||||
customElements.define("generic-field", GenericField);
|
||||
|
||||
class GenericForm extends HTMLElement {
|
||||
fields = {}
|
||||
form = {}
|
||||
fields = {};
|
||||
form = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.innerHTML = `
|
||||
|
||||
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.styleElement = document.createElement("style")
|
||||
this.styleElement.innerHTML = `
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
width:90%
|
||||
|
||||
}
|
||||
|
||||
div {
|
||||
|
||||
background-color: #0f0f0f;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 400px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
width:100%;
|
||||
height:100%;
|
||||
form {
|
||||
height:100%;
|
||||
width: 100%;
|
||||
width: 80%;
|
||||
}
|
||||
}`
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.appendChild(this.styleElement)
|
||||
this.container.classList.add("generic-form-container")
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url');
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadForm(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No URL provided!";
|
||||
}
|
||||
div {
|
||||
min-width:350px;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
form {
|
||||
height: 100%;
|
||||
width: 80%;
|
||||
}
|
||||
}`;
|
||||
|
||||
async loadForm(url) {
|
||||
const me = this
|
||||
this.container = document.createElement("div");
|
||||
this.container.appendChild(this.styleElement);
|
||||
this.container.classList.add("generic-form-container");
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const preloadedForm = this.getAttribute("preloaded-structure");
|
||||
if (preloadedForm) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
me.form = await response.json();
|
||||
|
||||
let fields = Object.values(me.form.fields)
|
||||
|
||||
fields = fields.sort((a,b)=>{
|
||||
console.info(a.index,b.index)
|
||||
return a.index - b.index
|
||||
})
|
||||
fields.forEach(field=>{
|
||||
const fieldElement = document.createElement('generic-field')
|
||||
me.fields[field.name] = fieldElement
|
||||
fieldElement.setAttribute("form", me)
|
||||
fieldElement.setAttribute("field", field)
|
||||
me.container.appendChild(fieldElement)
|
||||
fieldElement.updateAttributes()
|
||||
fieldElement.addEventListener("change",(e)=>{
|
||||
me.form.fields[e.detail.name].value = e.detail.value
|
||||
})
|
||||
fieldElement.addEventListener("click",async (e)=>{
|
||||
if(e.detail.type == "button"){
|
||||
if(e.detail.value == "submit")
|
||||
{
|
||||
const isValid = await me.validate()
|
||||
if(isValid){
|
||||
const isProcessed = await me.submit()
|
||||
console.info({processed:isProcessed})
|
||||
const form = JSON.parse(preloadedForm);
|
||||
this.constructForm(form);
|
||||
} catch (error) {
|
||||
console.error(error, preloadedForm);
|
||||
}
|
||||
}
|
||||
const url = this.getAttribute("url");
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/")
|
||||
? window.location.origin + url
|
||||
: new URL(window.location.origin + "/http-get");
|
||||
if (!url.startsWith("/")) {
|
||||
fullUrl.searchParams.set("url", url);
|
||||
}
|
||||
this.loadForm(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No URL provided!";
|
||||
}
|
||||
}
|
||||
|
||||
async constructForm(formPayload) {
|
||||
try {
|
||||
this.form = formPayload;
|
||||
|
||||
let fields = Object.values(this.form.fields);
|
||||
|
||||
let hasAutoFocus = Object.keys(this.fields).length !== 0;
|
||||
|
||||
fields.sort((a, b) => a.index - b.index);
|
||||
fields.forEach((field) => {
|
||||
const updatingField = field.name in this.fields;
|
||||
|
||||
this.fields[field.name] ??= document.createElement("generic-field");
|
||||
|
||||
const fieldElement = this.fields[field.name];
|
||||
|
||||
fieldElement.setAttribute("form", this);
|
||||
fieldElement.setAttribute("field", field);
|
||||
|
||||
fieldElement.updateAttributes();
|
||||
|
||||
if (!updatingField) {
|
||||
this.container.appendChild(fieldElement);
|
||||
|
||||
if (!hasAutoFocus && field.tag === "input") {
|
||||
fieldElement.focus();
|
||||
hasAutoFocus = true;
|
||||
}
|
||||
|
||||
fieldElement.addEventListener("change", (e) => {
|
||||
this.form.fields[e.detail.name].value = e.detail.value;
|
||||
});
|
||||
|
||||
fieldElement.addEventListener("click", async (e) => {
|
||||
if (e.detail.type === "button" && e.detail.value === "submit") {
|
||||
const isValid = await this.validate();
|
||||
if (isValid) {
|
||||
const saveResult = await this.submit();
|
||||
if (saveResult.redirect_url) {
|
||||
window.location.pathname = saveResult.redirect_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
async validate(){
|
||||
const url = this.getAttribute("url")
|
||||
const me = this
|
||||
let response = await fetch(url,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({"action":"validate", "form":me.form})
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const form = await response.json()
|
||||
Object.values(form.fields).forEach(field=>{
|
||||
if(!me.form.fields[field.name])
|
||||
return
|
||||
me.form.fields[field.name].is_valid = field.is_valid
|
||||
if(!field.is_valid){
|
||||
me.fields[field.name].setInvalid()
|
||||
me.fields[field.name].setErrors(field.errors)
|
||||
console.info(field.name,"is invalid")
|
||||
}else{
|
||||
me.fields[field.name].setValid()
|
||||
fieldElement.addEventListener("submit", async (e) => {
|
||||
const isValid = await this.validate();
|
||||
if (isValid) {
|
||||
const saveResult = await this.submit();
|
||||
if (saveResult.redirect_url) {
|
||||
window.location.pathname = saveResult.redirect_url;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
me.fields[field.name].setAttribute("field",field)
|
||||
me.fields[field.name].updateAttributes()
|
||||
})
|
||||
Object.values(form.fields).forEach(field=>{
|
||||
console.info(field.errors)
|
||||
me.fields[field.name].setErrors(field.errors)
|
||||
})
|
||||
console.info({XX:form})
|
||||
return form['is_valid']
|
||||
}
|
||||
async submit(){
|
||||
const me = this
|
||||
const url = me.getAttribute("url")
|
||||
const response = await fetch(url,{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({"action":"submit", "form":me.form})
|
||||
});
|
||||
return await response.json()
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
|
||||
}
|
||||
customElements.define('generic-form', GenericForm);
|
||||
|
||||
async loadForm(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.constructForm(await response.json());
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async validate() {
|
||||
const url = this.getAttribute("url");
|
||||
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: "validate", form: this.form }),
|
||||
});
|
||||
|
||||
const form = await response.json();
|
||||
Object.values(form.fields).forEach((field) => {
|
||||
if (!this.form.fields[field.name]) {
|
||||
return;
|
||||
}
|
||||
this.form.fields[field.name].is_valid = field.is_valid;
|
||||
if (!field.is_valid) {
|
||||
this.fields[field.name].setInvalid();
|
||||
this.fields[field.name].setErrors(field.errors);
|
||||
} else {
|
||||
this.fields[field.name].setValid();
|
||||
}
|
||||
this.fields[field.name].setAttribute("field", field);
|
||||
this.fields[field.name].updateAttributes();
|
||||
});
|
||||
Object.values(form.fields).forEach((field) => {
|
||||
this.fields[field.name].setErrors(field.errors);
|
||||
});
|
||||
return form["is_valid"];
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const url = this.getAttribute("url");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: "submit", form: this.form }),
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("generic-form", GenericForm);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
pre { line-height: 125%; white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word; }
|
||||
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
|
||||
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||
.hll { background-color: #ffffcc }
|
||||
.c { color: #3D7B7B; font-style: italic } /* Comment */
|
||||
.err { border: 1px solid #F00 } /* Error */
|
||||
.k { color: #008000; font-weight: bold } /* Keyword */
|
||||
.o { color: #666 } /* Operator */
|
||||
.ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
|
||||
.cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
|
||||
.cp { color: #9C6500 } /* Comment.Preproc */
|
||||
.cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
|
||||
.c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
|
||||
.cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
|
||||
.gd { color: #A00000 } /* Generic.Deleted */
|
||||
.ge { font-style: italic } /* Generic.Emph */
|
||||
.ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */
|
||||
.gr { color: #E40000 } /* Generic.Error */
|
||||
.gh { color: #000080; font-weight: bold } /* Generic.Heading */
|
||||
.gi { color: #008400 } /* Generic.Inserted */
|
||||
.go { color: #717171 } /* Generic.Output */
|
||||
.gp { color: #000080; font-weight: bold } /* Generic.Prompt */
|
||||
.gs { font-weight: bold } /* Generic.Strong */
|
||||
.gu { color: #800080; font-weight: bold } /* Generic.Subheading */
|
||||
.gt { color: #04D } /* Generic.Traceback */
|
||||
.kc { color: #008000; font-weight: bold } /* Keyword.Constant */
|
||||
.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
|
||||
.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
|
||||
.kp { color: #008000 } /* Keyword.Pseudo */
|
||||
.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
|
||||
.kt { color: #B00040 } /* Keyword.Type */
|
||||
.m { color: #666 } /* Literal.Number */
|
||||
.s { color: #BA2121 } /* Literal.String */
|
||||
.na { color: #687822 } /* Name.Attribute */
|
||||
.nb { color: #008000 } /* Name.Builtin */
|
||||
.nc { color: #00F; font-weight: bold } /* Name.Class */
|
||||
.no { color: #800 } /* Name.Constant */
|
||||
.nd { color: #A2F } /* Name.Decorator */
|
||||
.ni { color: #717171; font-weight: bold } /* Name.Entity */
|
||||
.ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
|
||||
.nf { color: #00F } /* Name.Function */
|
||||
.nl { color: #767600 } /* Name.Label */
|
||||
.nn { color: #00F; font-weight: bold } /* Name.Namespace */
|
||||
.nt { color: #008000; font-weight: bold } /* Name.Tag */
|
||||
.nv { color: #19177C } /* Name.Variable */
|
||||
.ow { color: #A2F; font-weight: bold } /* Operator.Word */
|
||||
.w { color: #BBB } /* Text.Whitespace */
|
||||
.mb { color: #666 } /* Literal.Number.Bin */
|
||||
.mf { color: #666 } /* Literal.Number.Float */
|
||||
.mh { color: #666 } /* Literal.Number.Hex */
|
||||
.mi { color: #666 } /* Literal.Number.Integer */
|
||||
.mo { color: #666 } /* Literal.Number.Oct */
|
||||
.sa { color: #BA2121 } /* Literal.String.Affix */
|
||||
.sb { color: #BA2121 } /* Literal.String.Backtick */
|
||||
.sc { color: #BA2121 } /* Literal.String.Char */
|
||||
.dl { color: #BA2121 } /* Literal.String.Delimiter */
|
||||
.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
|
||||
.s2 { color: #BA2121 } /* Literal.String.Double */
|
||||
.se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
|
||||
.sh { color: #BA2121 } /* Literal.String.Heredoc */
|
||||
.si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
|
||||
.sx { color: #008000 } /* Literal.String.Other */
|
||||
.sr { color: #A45A77 } /* Literal.String.Regex */
|
||||
.s1 { color: #BA2121 } /* Literal.String.Single */
|
||||
.ss { color: #19177C } /* Literal.String.Symbol */
|
||||
.bp { color: #008000 } /* Name.Builtin.Pseudo */
|
||||
.fm { color: #00F } /* Name.Function.Magic */
|
||||
.vc { color: #19177C } /* Name.Variable.Class */
|
||||
.vg { color: #19177C } /* Name.Variable.Global */
|
||||
.vi { color: #19177C } /* Name.Variable.Instance */
|
||||
.vm { color: #19177C } /* Name.Variable.Magic */
|
||||
.il { color: #666 } /* Literal.Number.Integer.Long */
|
||||
@@ -1,43 +1,56 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// The following JavaScript code defines a custom HTML element `<html-frame>` that loads and displays HTML content from a specified URL. If the URL is provided as a markdown file, it attempts to render it as HTML.
|
||||
|
||||
// Uses the `HTMLElement` class and the methods `attachShadow`, `createElement`, `fetch`, and `define` to extend and manipulate HTML elements.
|
||||
|
||||
// MIT License
|
||||
|
||||
class HTMLFrame extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.container = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.container = document.createElement("div");
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame")
|
||||
const url = this.getAttribute('url');
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame");
|
||||
let url = this.getAttribute("url");
|
||||
if (!url.startsWith("https")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
if(url.endsWith(".md")){
|
||||
const parent = this
|
||||
const markdownElement = document.createElement('div')
|
||||
markdownElement.innerHTML = html
|
||||
this.outerHTML = html
|
||||
}else{
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/")
|
||||
? window.location.origin + url
|
||||
: new URL(window.location.origin + "/http-get");
|
||||
if (!url.startsWith("/")) {
|
||||
fullUrl.searchParams.set("url", url);
|
||||
}
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
}
|
||||
customElements.define('html-frame', HTMLFrame);
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
if (url.endsWith(".md")) {
|
||||
const markdownElement = document.createElement("div");
|
||||
markdownElement.innerHTML = html;
|
||||
this.outerHTML = html;
|
||||
} else {
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("html-frame", HTMLFrame);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "snek",
|
||||
"name": "Snek",
|
||||
"description": "Danger noodle",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false,
|
||||
"screenshots": [],
|
||||
"dir": "ltr",
|
||||
"lang": "en-US",
|
||||
"launch_path": "/web.html",
|
||||
"short_name": "Snek",
|
||||
"start_url": "/web.html",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/image/snek192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/image/snek512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,39 +1,48 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This JavaScript class defines a custom HTML element <markdown-frame> that fetches and loads content from a specified URL into a shadow DOM.
|
||||
|
||||
// Utilizes built-in JavaScript functionalities and fetching APIs. No external libraries or imports are used.
|
||||
|
||||
// The MIT License (MIT)
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
class HTMLFrame extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.container = document.createElement('div');
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.container = document.createElement("div");
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame")
|
||||
const url = this.getAttribute('url');
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get")
|
||||
if(!url.startsWith("/"))
|
||||
fullUrl.searchParams.set('url', url)
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
}
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
this.container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
connectedCallback() {
|
||||
this.container.classList.add("html_frame");
|
||||
const url = this.getAttribute("url");
|
||||
if (url) {
|
||||
const fullUrl = url.startsWith("/")
|
||||
? window.location.origin + url
|
||||
: new URL(window.location.origin + "/http-get");
|
||||
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
|
||||
this.loadAndRender(fullUrl.toString());
|
||||
} else {
|
||||
this.container.textContent = "No source URL!";
|
||||
}
|
||||
}
|
||||
customElements.define('markdown-frame', HTMLFrame);
|
||||
|
||||
async loadAndRender(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
this.container.innerHTML = html;
|
||||
} catch (error) {
|
||||
this.container.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("markdown-frame", HTMLFrame);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Written by retoor@molodetz.nl
|
||||
|
||||
// This code defines custom web components to create and interact with a tile grid system for displaying images, along with an upload button to facilitate image additions.
|
||||
|
||||
// No external libraries or dependencies are used other than standard web components.
|
||||
|
||||
// MIT License
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
class TileGridElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.gridId = this.getAttribute("grid");
|
||||
this.component = document.createElement("div");
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
console.log("connected");
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.textContent = `
|
||||
.grid {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid .tile {
|
||||
margin: 10px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background-color: #ccc;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
.grid .tile:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
this.component.appendChild(this.styleElement);
|
||||
this.container = document.createElement("div");
|
||||
this.container.classList.add("gallery");
|
||||
this.component.appendChild(this.container);
|
||||
}
|
||||
|
||||
addImage(src) {
|
||||
const item = document.createElement("img");
|
||||
item.src = src;
|
||||
item.classList.add("tile");
|
||||
item.style.width = "100px";
|
||||
item.style.height = "100px";
|
||||
this.container.appendChild(item);
|
||||
}
|
||||
|
||||
addImages(srcs) {
|
||||
srcs.forEach((src) => this.addImage(src));
|
||||
}
|
||||
|
||||
addElement(element) {
|
||||
element.classList.add("tile");
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.component = document.createElement("div");
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
window.u = this;
|
||||
}
|
||||
|
||||
get gridSelector() {
|
||||
return this.getAttribute("grid");
|
||||
}
|
||||
grid = null;
|
||||
|
||||
addImages(urls) {
|
||||
this.grid.addImages(urls);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
console.log("connected");
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.textContent = `
|
||||
.upload-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.upload-button input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
.upload-button label {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
background-color: #ccc;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-button label:hover {
|
||||
background-color: #999;
|
||||
}
|
||||
`;
|
||||
this.shadowRoot.appendChild(this.styleElement);
|
||||
this.container = document.createElement("div");
|
||||
this.container.classList.add("upload-button");
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.addEventListener("change", (e) => {
|
||||
const files = e.target.files;
|
||||
const urls = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
urls.push(e.target.result);
|
||||
if (urls.length === files.length) {
|
||||
this.addImages(urls);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(files[i]);
|
||||
}
|
||||
});
|
||||
const label = document.createElement("label");
|
||||
label.textContent = "Upload Images";
|
||||
label.appendChild(input);
|
||||
this.container.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("upload-button", UploadButton);
|
||||
customElements.define("tile-grid", TileGridElement);
|
||||
|
||||
class MeniaUploadElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.component = document.createElement("div");
|
||||
alert("aaaa");
|
||||
this.shadowRoot.appendChild(this.component);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.container = document.createElement("div");
|
||||
this.component.style.height = "100%";
|
||||
this.component.style.backgroundColor = "blue";
|
||||
this.shadowRoot.appendChild(this.container);
|
||||
|
||||
this.tileElement = document.createElement("tile-grid");
|
||||
this.tileElement.style.backgroundColor = "red";
|
||||
this.tileElement.style.height = "100%";
|
||||
this.component.appendChild(this.tileElement);
|
||||
|
||||
this.uploadButton = document.createElement("upload-button");
|
||||
this.component.appendChild(this.uploadButton);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("menia-upload", MeniaUploadElement);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user